How to write an app for the osvauld shell. For humans and agents alike; the reference app is
kanban (demo_apps/kanban/ — the files that use everything in this guide), and demo_apps/tally is the smallest complete app.
An app is a folder of .lua files with a root main.lua that returns the view function.
require("theme") loads theme.lua from your own folder (ui/widgets.lua ↔
require("ui/widgets")); required modules run once per app open and are cached — so anything
that must happen once (seeding a document) goes at module scope, guarded, because module
scope runs again on every open. manifest.osv is optional and only supplies the display name.
local C = require("theme")
local t = doc:open("tally") -- this app's document, by name
if not t.count then -- guarded seed
t:set({ "count" }, doc.map({ n = 0 }))
end
return function()
local n = t.count and t.count.n or 0
return ui.col({
id = "tally",
grow = true,
center = true,
fill = C.bg,
ui.text({ tostring(n), color = C.text, font_size = 72, no_wrap = true }),
ui.button({
id = "inc", h = 34, radius = 8, center = true, fill = C.accent,
ui.text({ "+1", color = "#ffffff", font_size = 13 }),
on_click = function() t:set({ "count", "n" }, n + 1) end,
}),
})
endThe architecture is Elm's: state, view, update.
- view is your returned function. It takes nothing and returns a description of the screen built from state. It is pure: no side effects, no input handling — just a description.
- update is your handlers (
on_clickand friends). They run after the frame, as messages; they change state, and the next view reflects it. Kanban routes every handler through oneupdate(msg)that dispatches onmsg.kindto anactionstable — that discipline is recommended, not required. - state lives in three places, each with a job — see State.
Two consequences to internalize:
- The description is rebuilt constantly — every message, every pointer move. Keep
viewcheap: derive, don't compute the world. Nothing is mutated in place; you never "update the UI", you change state and describe it again. ids are the continuity mechanism. The shell keeps per-element state (scroll offsets, animation progress, input carets) keyed byid. An element without one is anonymous — which is fine for static things, and exactly whyscroll_*,on_drag,on_drop,fadeand everyui.inputneed one.
Three kinds of table exist in an app, and only the first draws:
| kind | example | reaches the screen |
|---|---|---|
| element | ui.col({ … }) |
yes |
| splice group | local body = {} … ui.col({ …, body }) |
its contents, spliced in |
| data | doc.map({ … }), stroke = { 1, C.line } |
no |
Only the ui.* constructors make elements — never hand-write tag = "col". A bare table
nested as a child splices its contents into the parent (kanban builds body imperatively and
nests it whole).
Children are positional entries and their order is meaning — it is the child list.
A bare string child becomes a text element; false drops out (ghost or false is the idiom
for conditional children).
Anything you can describe with coordinates, you can draw. Paths and brushes are compiled once
into immutable resources, assembled into a visual, and published as a normal leaf with
ui.frame({ visual = v, …normal props… }). Frame dimensions are intrinsic layout claims, not an
implicit clip or scale.
| call | fields | notes |
|---|---|---|
gfx.path(commands) |
positional list of commands | up to 65536; drawing before move is an error |
gfx.solid(color) |
a CSS color string | |
gfx.linear_gradient({…}) |
from = {x, y} · to = {x, y} · stops = {{offset, color}, …} · extend |
2–64 stops, offsets 0–1; extend is pad (default), repeat, reflect |
gfx.frame({…items}) |
width · height · (baseline) + items as positional children |
width/height required; the result reads them back (f.width, f.height) |
gfx.fill({…}) |
path · brush · (rule) |
rule is nonzero (default) or evenodd |
gfx.stroke({…}) |
path · brush · width · (cap · join · miter_limit · dashes · dash_offset) |
cap: butt (default) · square · round. join: miter (default) · bevel · round. miter_limit 4, dashes {} (max 64), dash_offset 0 |
gfx.group({…items}) |
transform = {xx, yx, xy, yy, dx, dy} + items |
|
gfx.instance({…}) |
visual (another frame) · transform |
placed by its local origin — account for a centered shape's radius |
fill, stroke, group and instance take an optional id — and that is what makes the
drawing touchable. Put the pointer handler on the ui.frame itself, and on_click/on_hover
report which named shape the pointer is on and where on it:
ui.frame({
id = "pie",
visual = v,
on_hover = function(e) hot = e.phase ~= "leave" and e.shape or nil end,
})Unnamed shapes are paint: the pointer falls through them to whatever is underneath. Where two named shapes overlap the later one wins, because that is the one you see. A named container answers as one shape and the names inside it stop being reachable — that is how you choose the granularity, a whole dial or each of its ticks — and the names inside an instanced visual are never reachable, since every instance would answer to the same ones. An id'd item with no brush is an invisible hit region. A stroke is hit within half its width of the line; caps, joins and dashes aren't modelled, so a dashed line is one line to the pointer.
Path commands are positional with exact arity: {"move", x, y}, {"line", x, y},
{"quad", cx, cy, x, y}, {"cubic", c1x, c1y, c2x, c2y, x, y}, {"close"}. Everywhere else in
gfx, fields are named — a stray positional entry in a fill or a named key in a path is an
error, not an ignored extra.
There is no text inside a frame, no arcs, and no internal clips yet; labels are ui.text
siblings positioned by layout, which is what the demo charts' axes do.
A drawing is a character or prop as pure data — a module of tables, numbers and strings, no
functions and no gfx handles — so an agent can edit it and a future editor can rewrite it.
gfx.drawing(module) validates it; drawing:pose(overrides) returns an ordinary gfx.frame.
-- hero.lua
return {
size = { 160, 240 },
parts = { -- list order is draw order
{ id = "arm_far", parent = "body", pivot = { 56, 98 }, shapes = { … } },
{ id = "body", pivot = { 80, 166 }, shapes = {
{ path = { {"move",66,88}, {"line",94,88}, … , {"close"} },
fill = "#f84aa7", stroke = { 2, "#1b1b3a" } },
} },
{ id = "hip", parent = "body", pivot = { 80, 166 } }, -- no shapes: a group
},
}
-- main.lua
local hero = gfx.drawing(require("hero"))
local waving = hero:pose({ arm_near = { rot = -150 }, head = { rot = 8 } })
ui.frame({ id = "hero", visual = waving })- Parts are what move; shapes are the painting inside a part — fill under stroke, sharing
one path. A shape needs
fill,strokeor both; strokes join and cap round. parentis the hierarchy and list order is draw order, independently: a far arm belongs to the body and still draws behind it. A parent may be listed after its child.- Coordinates, paths and
pivot(required) are all in the drawing's own space. - A pose override is
{ x, y, rot, scale }—rotin degrees — about the part's pivot; children follow. Naming a part that doesn't exist is an error. - Each posed part is a named group, so
on_hover/on_clickon theui.framereport the part id ase.shape. posebuilds a new frame: pose once at module scope, not inview. Clips,useand paint beyond solid colours are not built yet —clips = …anduse = …are errors.
demo_apps/hero is the reference.
ui.world is an element that keeps state between frames: a retained world of entities, kept by
id. You describe the entities; the world spawns, keeps and despawns them to match.
local hero = gfx.drawing(require("hero"))
ui.world({
id = "room", width = 720, height = 400, fill = "#2a3b33",
on_hover = function(e) hot = e.shape end, -- e.shape is the entity id
{ id = "hero", pos = { 120, 80 }, drawing = hero },
friend and { id = "friend", pos = { 320, 100 }, drawing = hero } or false,
})- Entities are the positional children, as data:
id,pos,drawing(agfx.drawinghandle) and optionallyclip(agfx.cliphandle),controller,flip,attach,collider,sensor,loose,groupandblocks— nothing else.falsedrops out, like a child element. List order is draw order, unless the world hasorder = "y": then whoever's feet (the bottom of the drawing's box) stand lower draws in front, ties keeping list order — a top-down room. posis where an entity spawns, and only that. Once it exists the world owns where it is; re-sending a differentposdoes not move it.- An id the description no longer lists is despawned. A new
drawinghandle replaces the look; make drawings once at module scope so the handle is stable. id,widthandheightare required; ordinary box and paint props (fill,radius,stroke) and pointer handlers apply to the element. The world clips to its box: a head or hand spilling past the edge is neither drawn nor hit there.- The world outlives hot reload and survives a hidden tab. It is dropped when a successful frame no longer draws its id. A bad description is an error and keeps the last good world.
flip = truemirrors the drawing within its box — a side view drawn facing right, shown facing left.attach = { to = "hero", part = "body", at = { 32, 120 } }carries the entity: its box's top-left rides atat— a point in the carrier's drawing at rest — as that part moves and animates (a flipped carrier mirrors the box too). It draws just in front of its carrier. Removeattachand it stays where it was last carried. The carrier must be described, not itself attached, and have the part.pivot = { x, y }(default{0, 0}, the box's top-left) is the point of the carried entity's own drawing placed onat— a hat's plug, a sword's grip. Under a flipped carrier, give the carried entityflip = truetoo; its mirrored pivot still lands on the point.turn = truemakes it take on the part's rotation, scale and mirror, about its pivot: a hat nods with the head, a sword swings with the hand. Without it the entity stays upright (a chest in the arms). A turned entity mirrors with its carrier, soflipwithturnis an error. Turning is drawing only: a carried entity has no body, and let go it lands upright.
collider = { circle = 16, at = { 80, 222 } }or{ rect = { 720, 12 } }makes the entity solid, in its drawing's units:at(default{0, 0}) is a circle's centre or a rect's top-left corner. An entity with a controller stops at solid entities and slides along them; one without never moves, so it is a wall. Give a character a small circle at its feet, not its whole drawing: top-down, the body stands up out of the floor, and only the feet meet a wall. Nothing checks where an entity spawns — aposinside a wall is the author's mistake.- The bridge's
dump_treeshows a world's entities on its element, underworld.entities: each one'sid,pos(box top-left),body(fixed,moved,thrown,looseornone),velocityper second,attached({ to, part }), thezonesit is in, and itsclip(time,length,looped). Check a world by reading it, not by probing pixels. - A carried entity is off the floor: its collider and sensor go while
attachis set. Let go, it keeps its carrier's momentum, slides, bounces off walls and settles where it stops — never inside a wall: carried, nothing stopped its footprint going into one, so let go it comes from its carrier's body to its spot and stops against whatever is in the way. Top-down, height is a pose, not a place: attach the thing where it would stand on the floor and let a clip lift its drawing into the hands; let go, afallclip drops the drawing back to a footprint that never left the floor (the demo'slift/fall). sensor = { circle = 72, at = { 48, 68 } }is a zone in the same shape words: it blocks nothing, andon_zonereports what comes into it and leaves it. An entity may have acollider, asensor, both, or neither.loose = true(orloose = { bounce = 0.9, friction = 0.5 }) hands an entity to physics: a walker pushes it, it bounces off what it hits and the floor slows it.bounceis the share of speed kept off a wall, 0 to 1 (default 0.5);frictionis speed lost per second, 0 or more (default 6, a heavy crate; a ball wants under 1). It needs acollider, and cannot have acontroller. At rest it sleeps, still loose, and the next push wakes it.group = "paddle"puts an entity's collider in a named group;blocks = { "paddle" }makes a collider stop only those groups. Without either, a collider is in the common group and stops everything. A hockey centre line isblocks = { "paddle" }: the paddles stop at it, the puck crosses. Both need acollider; a world has at most 31 group names.
A clip is animation as data, played by the world in Rust — no Lua runs per frame.
local open = gfx.clip({
length = 0.5, -- seconds; required
loop = false, -- default: play once and hold the last pose
tracks = { lid = { rot = { {0, 0}, {0.5, -100, "in_out"} } } },
})
ui.world({ id = "room", width = 720, height = 400,
{ id = "chest", pos = { 520, 240 }, drawing = chest, clip = lid_open and open or nil },
})tracksmaps a part id to propertiesx,y,rot(degrees) andscale, each a list of keys{time, value, easing?}— offsets from the rest pose about the part's pivot, likepose. Key times rise strictly within0..length.- Easing is
"linear"(the default) or"in_out", and shapes the segment arriving at that key. Before its first key a track holds the first value; after its last, the last. A looped clip's last key should match its first. - A clip plays from the moment its handle first appears on the entity, on the runtime's
frame clock (virtual offscreen, so
rpc.advancelands on exact times). Re-sending the same handle keeps it playing; a different handle restarts; noclipreturns to rest. Make clips once at module scope, and switch clips by switching handles. - A clip track naming a part the entity's drawing lacks is skipped, not an error — a drawing
edited live must not stop the world. The console says so once per entity and part (again only
if the part comes back and goes missing anew); the clip's other tracks play. An
attachto a missing part stays an error: there is nowhere to put the carried thing. Unknown fields are errors —events, speed, transitions and layers are not built yet. - A world with a clip playing drives its own frame ticks, so
on_frameon aui.worldis an error; put it on an element around the world.
A controller moves its entity from held keys, in Rust — Lua describes it once and runs nothing per key or per frame.
local wasd = {
speed = 160, -- units a second; required
axis_x = { neg = "KeyA", pos = "KeyD" }, -- at least one axis
axis_y = { neg = "KeyW", pos = "KeyS" },
}
{ id = "hero", pos = { 120, 80 }, drawing = hero, clip = idle, controller = wasd }- Key names are physical codes, the same as
on_key'se.code. A misspelt code ("W"for"KeyW") is not caught — it simply never matches. - Both keys of an axis held cancel; a diagonal is no faster than a straight line. Movement uses the
runtime's frame
dt, capped at 0.1s after a stall, sorpc.frame(n)is the exact way to drive it offscreen —rpc.advancemoves by at most one capped step. - A world with a controller or actions takes keyboard input itself (it attaches
on_key, and releases every key on blur), soon_keyon aui.worldis an error. A world with neither leaves keys alone. - There are no walls yet: nothing stops an entity leaving the world's box.
The world does the per-frame work; the moments come to Lua to decide on. A handler runs a few times a second at most, never once a frame.
actions = { interact = "KeyE", jump = "Space" },
on_action = function(e) if e.action == "interact" then toggle_lid() end end,
on_move = function(e) print(e.id, e.dx, e.dy) end, -- -1/0/1 each; 0, 0 is stoppedactionsmaps an action name to a key code.on_action(e)getse.actionon a fresh press — a held key's repeats are not presses.actionsandon_actioncome together or not at all.on_move(e)getse.id,e.dx,e.dywhen a controlled entity's held direction changes: it starts, turns or stops. Standing still at spawn is not a change.- Actions are the world's, not an entity's: the press is the player's, and Lua decides which entity it concerns.
on_clip_end(e)getse.idwhen a once clip on that entity reaches its end — once per play; re-sending the same handle is the same play. A looped clip never ends.on_zone(e)getse.id(the entity whose sensor it is),e.whoande.phase—"enter"or"leave"— when something solid or moving comes into the zone or goes out of it; walls never count. Taking a sensor away (carrying, despawning) is a leave for whoever was in it; a despawnedwholeaves quietly. (on_enteris the Enter key on an input, so the world's ison_zone, phased likeon_hover.) The demo's reach to the chest is one flag:near = e.phase == "enter", and E picks up only whennear.on_hit(e)getse.id,e.whoande.speedwhen a loose (or thrown) thing comes into contact with something solid:e.idis the moving one,e.whowhat it met, ande.speedhow fast they closed along the contact, in units a second — a tap is slow, a slap fast. Once per meeting: resting against a wall afterwards is not more hits, and under 1 unit a second is settling, not a hit. Two loose things meeting each get one. A walker meeting a wall is not a hit — a walker stops itself — only what Rapier moves hits.- A jump is all three together, with no jump in Rust:
on_actionsetsjumping, the hero describesclip = jumping and jump or …(a once clip liftingbody, which the other parts hang off — the feet stay put, so draw order ignores it), andon_clip_endclearsjumping.
A description never moves an entity that already exists: its pos is where it spawns. To change
one at a moment — a puck back on the spot, a striker launched — a handler commands the world:
world("rink"):set("puck", { pos = { 434, 234 } }) -- put it there
world("rink"):set("puck", { velocity = { 600, 0 } }) -- send it off; { 0, 0 } stops itposis the drawing box's top-left, as in the description;velocityis per second, and only aloosething has one to set. Either may be left out; any other field is an error.- It applies at once — the dump shows it before the next frame. Something put inside a solid is pushed out, as when it spawns there.
- Only in handlers:
setinsideviewis an error, since a description only describes. A carried entity goes where its carrier puts it, so it cannot be set. - The names are the dump's: what
setwrites is what an agent reads back.
A moment later is a timer, kept by Rust on the world's frame clock — Lua runs once, when it fires:
world("rink"):after(1.5, "faceoff") -- on_timer gets e.name == "faceoff" then
world("rink"):cancel("faceoff") -- never mind
on_timer = function(e) if e.name == "faceoff" then put_puck_back() end end, -- on ui.world- It counts from the world's next frame, since a handler has no clock of its own. The same name
again starts it over — a cooldown is
afteron every press. - A pending timer keeps the world ticking; it only runs while the world is on screen. The dump
lists them as
timers = { { name, left } }besideentities.
The world reports a change of direction; Lua picks what the entity shows. A top-down character is drawn as views — front, back, side — sharing part ids so one clip plays on all of them.
local views = {
down = { drawing = hero, walk = walk },
up = { drawing = hero_back, walk = walk },
right = { drawing = hero_side, walk = walk_side },
left = { drawing = hero_side, walk = walk_side, flip = true }, -- the side view, mirrored
}
on_move = function(e)
walking = e.dx ~= 0 or e.dy ~= 0
face = face_for(e.dx, e.dy, face) -- the app's own rule; stopping keeps the facing
end,
{ id = "hero", drawing = views[face].drawing, flip = views[face].flip,
clip = walking and views[face].walk or idle, controller = wasd },- The clip switches on the frame after the move starts: Rust moves on the tick, Lua re-describes
after
on_move. - Hits still name the entity, whatever view is showing.
demo_apps/world is the reference: a hero who idles and walks with WASD, and a chest whose lid
opens on click or on E, through on_action.
gfx.scene3d compiles a bounded immutable scene containing a perspective camera and up to 256
built-in cubes. Display it with ui.scene3d({ scene = scene, ...normal layout props... }); the
viewport size comes from ordinary layout. Camera fields are eye, target, optional up,
fov_y in degrees, near and far. Object fields are a stable id, optional mesh = "cube",
position, quaternion rotation = {x, y, z, w}, positive scale and CSS color.
This is a rendering proof, not the public Environment API. It currently supports one visible 3D
viewport, 4× MSAA, simple directional lighting and app-driven rebuilds. The model-viewer demo uses
on_drag for orbit and on_wheel for zoom. An on_click on the scene leaf ray-picks the
nearest visible cube and reports its stable ID, world hit point, normal and distance. GLB assets,
hover picking and correct foreground Vello overlay composition remain unbuilt. DumpTree includes
the validated camera,
objects, transforms and colors so an agent can inspect the same scene declaration that renders.
Flexbox: ui.col stacks children vertically, ui.row lays them out horizontally. Children
that should share the leftover space use grow (a bool = equal share, a number = weighted
share); children that should fill the other axis sit in a stretch parent.
Sizing — w/h fixed sizes · min_w/max_w/min_h/max_h bounds (a grow child
without a floor can be squeezed to nothing) · w_full/h_full/full · no_shrink (refuse
to be squeezed: labels, badges).
Alignment — center centers on both axes · align_center centers on the cross axis only
(cards under a title) · stretch makes children fill the cross axis.
Spacing — gap between children · pad on all sides, px/py per axis · mt/mb
margins.
Scrolling — scroll_x/scroll_y on a container makes it scrollable on that axis;
children keep their natural size there. A scrolling container with grow uses the remaining
main-axis space instead of letting its content enlarge its viewport. Needs an id.
Overlay positioning — absolute takes the element out of the flow; top/left/
right/bottom position it in viewport coordinates. This is how kanban draws its drag ghost
and its modal scrim. A portal popover uses ui.overlay: exactly two positional children, the
anchor then the root-painted panel. side is bottom (default), top, left, or right;
align is start (default), center, or end; on_dismiss adds a click-away catcher and,
like every handler, needs an id:
ui.overlay({
id = "add-menu",
side = "top",
align = "start",
on_dismiss = function() open = false end,
ui.button({ "Add" }),
ui.col({ ui.text({ "Popover" }) }),
})The panel escapes ancestor clips and stays screen-sized. Its element anchor follows stable placement through zoom, pan, authored scale, and offset, but ignores transient press-scale so an opening popover does not wobble while its button settles. A point anchor supplied by Rust is already screen-space.
Text — ui.text({ "label", … }) needs its label as child 1. It wraps like a paragraph by
default; no_wrap makes a label. Measure happens for you.
When the window resizes — nothing to handle: relayout is automatic. An app is responsive
exactly to the extent its tree uses full, grow, stretch and scroll instead of fixed
sizes — kanban fills the viewport, gives its board row a definite h_full, and lets only the
cards region scroll, so window drags and added cards do not enlarge the board.
Buttons have no defaults — ui.button is a ui.row and nothing more; the fill, hover
states, padding and centering are all yours to declare.
Inputs — ui.input and ui.text_area require value, id and on_input = function(v)
(three mandatory props, no children — put the button beside it). on_enter, on_esc and
autofocus are extras. The pattern is a draft in ui.state, shown in
Patterns.
Colors are any CSS color string: "#0d1117", "#rrggbbaa", "rgba(13,17,23,0.72)",
"hsl(212,92%,58%)", named colors.
Nine, and no others. Anything else is unknown tag.
| constructor | children | required | notes |
|---|---|---|---|
ui.col({…}) |
any | — | vertical flex |
ui.row({…}) |
any | — | horizontal flex |
ui.button({…}) |
any | — | a ui.row and nothing more — no fill, padding or centering of its own |
ui.text({ "label", … }) |
the label is child 1 | — | wraps like a paragraph; no_wrap makes it a label |
ui.input({…}) |
none | value · id · on_input |
extras: on_enter · on_esc · autofocus |
ui.text_area({…}) |
none | value · id · on_input |
same, multi-line |
ui.frame({ visual = v, … }) |
none | visual (a gfx.frame) |
the frame's width/height are its layout claim |
ui.scene3d({ scene = s, … }) |
none | scene (a gfx.scene3d) |
experimental; viewport size comes from layout |
ui.overlay({ anchor, panel, … }) |
exactly two | — | takes only id · side · align · on_dismiss — no box or paint props; style the panel child instead |
ui.state(id, init) is not an element — it is per-viewer scratch, see State.
Every prop below works on every element (ui.overlay excepted, above). Anything not in this
list is an error, not a warning — there is no silent ignore, so a typo shows up as a red box
in place rather than as a missing effect. Types are checked too (expected a number got string).
Shorthands are applied before the longhands that override them, whatever order the Lua table
happens to be in: full before w/h, size before both, pad before px/py, fade_in
before fade.
| group | prop | value |
|---|---|---|
| box | full · w_full · h_full |
bool — fill the parent on both axes / one |
size |
{w, h} |
|
w · h |
number | |
min_w · max_w · min_h · max_h |
number (a grow child with no floor can be squeezed to nothing) |
|
grow |
bool, or a number for a weighted share — grow = 2 beside grow = true is 2:1 |
|
no_shrink |
bool — refuse to be squeezed (labels, badges) | |
wrap |
bool — flex children onto more lines | |
| spacing | pad · px · py · gap · mt · mb |
number |
| alignment | center · align_center · stretch |
bool — both axes / cross axis only / children fill the cross axis |
| positioning | absolute |
bool — out of the flow |
top · left · right · bottom |
number, viewport coordinates | |
offset |
{dx, dy} — shifts paint, not layout |
|
scale |
number — scales this subtree, layout unchanged | |
| paint | fill · color |
a CSS color string (color is the text one) |
radius · opacity · font_size |
number | |
stroke |
{width, color} |
|
stroke_dash |
{width, color, dash, gap} |
|
no_wrap |
bool | |
| hover / press | hover_fill · press_fill |
color — the transition to it is automatic |
hover_stroke · press_stroke |
{width, color} |
|
tint |
milliseconds — the fade time for a hover brightening, not a color | |
press_scale |
number, e.g. 0.96 — needs id and on_click |
|
| animation | fade_in |
ms |
fade |
{target_opacity, ms} |
|
slide_in |
{{dx, dy}, ms} — a nested pair, then the duration |
|
| pointer | system_cursor |
bool — false hides the system pointer while over this element, for an app that draws its own (demo_apps/pointer/cursor.lua) |
cursor |
a look name ("grab", "text", any string your cursor knows) or a gfx.frame to draw — hover handlers get the topmost declared one as e.look |
|
| viewport | zoomable · zoom_x |
bool — Ctrl+wheel zooms children around the pointer, both axes or x only. Needs id. |
| scroll | scroll_x · scroll_y |
bool. Needs id. |
| input | value · autofocus |
string · bool |
Four props need an id because the shell keys state by it: scroll_* (offset), zoomable /
zoom_x (camera), press_scale (spring), and every handler (dispatch). Asking for one without
an id is an error naming the prop.
Not exposed to Lua yet, so don't go looking: right-click, text measurement or alignment inside a
gfx frame, hit-testing individual Frame shapes, and reading back layout.
Every handler needs an id, and no two elements may share an id and a handler. A handler is
found by id + name when its event is delivered, not when the view was built, so a click still
reaches its element when a peer edit lands between press and release — and is dropped if the
element is gone.
A handler that carries more than one value is called with one table, not positional
arguments — function(e), and every value is a named field on e. The two that carry nothing or
a single value keep their plain form: on_enter, on_esc, on_faded_out take nothing, and
on_input = function(v) takes the new text.
This is why: positionally, a short or mis-ordered signature binds the wrong values and keeps
running. Writing function(phase, x, y, dx, dy, shape, sx, sy) for on_drag puts scale into
shape, so shape is the number 1, looks like a shape id, and fails every lookup in silence. A
wrong key is nil, which is loud the moment you index it, and a field added later can never
shift the meaning of one already there.
on_drag = function(e)
if e.phase == "start" then grab(e.shape, e.sx, e.sy) end
end-
on_click(e)—e.x, e.yare where the click landed, from the element's top-left corner in its own units, zoom and scroll undone: a click on a 1400×900 canvas reports canvas numbers whatever the camera is doing. Fired from the bridge, with no layout, they are0, 0. Inside azoomable, a press that travels past 5pt pans instead. On aui.scene3d, a visible cube hit also suppliese.object,e.distance,e.world_x/y/zande.normal_x/y/z; these fields are absent when the ray hits no object. -
on_hover(e)—e.phaseis"enter"/"move"/"leave",e.x, e.yason_click(outside the element on"leave"),e.downtrue while the primary button is held — a press or release under a still pointer fires a"move", so a cursor the app draws can show it.e.lookis thecursordeclared by the topmost element under the pointer that declares one — a name or thegfx.frameit supplied — ornil; a change of look is a"move"too. Declare looks on the things being pointed at and draw the cursor once at the root: neither has to know the other. An element is hovered while the pointer is inside it, likehover_fill: a parent stays hovered over its children, and an element painted on top doesn't hide the one below — check your own geometry if that matters. It is sampled every frame as well as on every pointer move, so geometry that drifts under a still pointer reports it: an element that slides under one enters where it arrives, and"move"fires when the shape beneath the pointer changes or slides, without the pointer having moved at all. What it will not do is repeat itself — a still pointer over still geometry says nothing, so"move"always means something actually changed. -
e.shape, e.sx, e.syon both of those name the shape inside aui.frame's visual that the pointer is on — see Frame visuals.e.shapeis theidyou gave the shape, ande.sx, e.syare the point in that shape's own coordinates, with itsgroupandinstancetransforms undone. All three are absent on an element that draws no frame, or when the pointer is on none of its named shapes. -
on_enter,on_esc,on_faded_out— plain callbacks, no argument. -
on_input = function(v)— an input's new text. -
on_drag(e)—e.phaseis"start"/"move"/"end".e.x, e.yare the pointer in the element's own units, ason_clickreports them (at"start", where the press landed, not where the 5pt slop ended).e.dx, e.dyare movement since the press,e.scalelets a root ghost match zoomed content, ande.origin_x, e.origin_yare the dragged element's screen-space origin — only a root-level ghost placing itself in screen space needs those.e.shape, e.sx, e.syare the shape the press grabbed: the same one for the whole gesture, whatever the pointer has since slid over, and reported even once the pointer leaves it — which is what holding something means. Needs anid.e.tis when the pointer event arrived: monotonic seconds since the app opened, on the same clock ason_frame'se.elapsed, so a release can be measured against the frames after it. Keep the last two(e.t, e.x)and a fling is(x - x_prev) / (t - t_prev)on"end"— noon_framerunning purely to hold a stopwatch. Usee.tand nevernow()for this: several moves usually arrive inside one frame, so anything sampled per frame divides by zero, andnow()is wall-clock and can step backwards.A press that travels past 5pt is a drag and fires no click. A press that travels less is a click, reported where it was released. No hand is perfectly still, so the second is ordinary.
The coordinate space is frozen at the press, not recomputed each move:
e.sx, e.syare measured in the space the shape had when you grabbed it, even if the shape has rotated or moved since. That is what makes them useful — they are a fixed grab offset for the whole gesture, so "where should this go now" ise.x - e.sx, computed the same way on every move. Read them as live coordinates instead and you write a feedback correction that fights itself, which looks like a broken drag rather than a coordinate-space mistake. -
on_drop(e)—e.phaseis"over"(while hovering) /"release";e.x, e.yare normalized to the drop target (0–1), soe.y < 0.5means "above the midline". Needs anid. -
on_wheel(e)—e.dx, e.dyare normalized logical wheel deltas (line wheels use 30 points per step). The topmost eligible handler consumes the wheel before scroll/zoom ancestors. Use it for app-owned cameras such asui.scene3d; ordinary scrolling should continue to usescroll_*. Needs anid. -
on_key(e)— general keyboard input on the last visible element with this handler.e.codeis the physical key name ("KeyW","ArrowLeft"), independent of keyboard layout;e.keyis the layout-dependent character or named key. Either may be absent if unknown.e.downande.repeatedare bools;e.shift,e.ctrl,e.alt,e.superare modifier bools. Ignore repeat for held movement.e.cancelled == truehas no key identity: clear all held keys on blur, text focus or surface change. Otherwise it is false. Text fields take priority; shell F5/F12 shortcuts do not reach the app. Typed text and IME are not game-key events. Needs anid. -
on_frame(e)— an experimental visual/prototyping loop.e.elapsedis monotonic Runner time ande.dtis clamped to 0.1 seconds after stalls; only Runner's first frame is guaranteed zero. Presence keeps repainting, so omit it to stop that request. Custom screenshots currently dispatch it too; it is not a fixed-step world scheduler. Needs anid.
Handlers run after the frame. A gesture should accumulate in your own state during
"start"/"move" and commit to the document once at the end — a drag is one write, not
sixty.
Three homes, pick by lifetime and audience:
| locals | ui.state(id, init) |
doc:open(name) |
|
|---|---|---|---|
| audience | this viewer | this viewer | everyone, forever |
| survives reopen | no | yes | yes |
| survives hot reload | no | yes | yes |
| cleaned up | never | when its id leaves a frame |
never |
- Plain locals (a module-scope table like kanban's
S = { drag = nil, … }) are right for gesture and interaction state: drags in flight, modal-open flags, resize widths mid-drag. They die with the app instance — usually what you want for pointer state. ui.state(id, init)is right for state keyed to an element that may come and go —"draft:" .. col_idis garbage-collected when its column disappears — and for anything that should survive a hot reload, like half-written drafts.- Documents are right for anything that is a claim about the work: a column's name, its width, a card's text. The next person to open this app sees the board somebody arranged.
Kanban uses all three at once — that split is the example to copy.
local board = doc:open("board") opens (or creates) a named document and returns the
mirror: reads are plain table indexes — board.columns[1].name, #board.cards — free
and instant. Writes are explicit:
board:set({ "cards", id, "col" }, "c-doing") -- path segments: names or ids
board:insert({ "cards" }, doc.map({ id = uuid(), col = "c-todo", text = "…" }))
board:delete({ "cards", id })
board:move({ "cards", id }, to) -- `to` is the index after removalWrite values are built with doc.map{…} / doc.list{…} / doc.text("…"); lists take
positional entries only — a stray named key is an error, not a silent extra field.
The rules that bite, once each:
- The mirror is a frame behind your own write. A write lands immediately; reads see it on the next view. Read what you need first, then write — never read back what you just wrote.
- Address by stable id, never by position. Stamp
id = uuid()at birth. The mirror is positional; a concurrent insert shifts it. :moveremoves then reinserts — dragging downward lands one slot short unless you nudge the target (seetarget_indexin kanban'smodel.lua).- Deleting while iterating collects the doomed ids first, deletes second — the list you are walking does not shrink until the next frame.
- An unchanged write is a no-op. Don't guard against writing a value that might already be there; the document skips it.
Ship an index.lua beside main.lua and your documents become searchable. It says what a
searchable record is; the shell does the rest — indexes on every save, keeps the index sealed
in the vault, and catches up on open (including docs written while the app was closed).
demo_apps/chat is the reference.
-- index.lua
return {
doc = "channel:*", -- a doc name, or a `prefix*` family of docs
each = { "messages" }, -- path to the collection: one record per entry
key = function(m) return m.id end, -- lists only: the stable id, read from the record
fields = function(id, m, doc) -- doc = the whole doc, for joins
return {
title = m.subject, -- optional; ranks above body
body = m.text,
facet = { author = m.author }, -- exact filters: `author:anu`
time = m.sent_at, -- optional; sortable
} -- return nil to leave the record out
end,
rank = "recent", -- "relevance" (default) or "recent"
}-- main.lua, anywhere — even in view
local hits = search.query("author:anu deploy", { limit = 20 })
for _, h in ipairs(hits) do
print(h.doc, h.id, h.score, h.snippet) -- open the record by (h.doc, h.id)
endThe rules:
index.luaruns in its own VM. Noui, nodoc, nogfx, norequire; records anddocare frozen copies — a write is an error. A brokenindex.luais reported in the console and the app keeps running on the last good index.- Strict, like the rest. An unknown key in the spec, in what
fieldsreturns, or insearch.query's options is an error naming it. A list collection withoutkeyis an error: a position is not an id. - Only what changed is re-indexed. Each record is fingerprinted;
fieldsruns again only for a record whose value changed. A change outsideeachre-runs every record of that doc (it may feed a join), and so does editingindex.lua. - Index ids for anything that changes; resolve names when you display. Index the author's id, not their display name, and a rename re-indexes nothing.
- Queries: words match title and body (all must match);
name:valuewords are exact facet filters.search.queryis cheap to call inview— the same query against an unchanged index is answered from memo. Hits are your own app's records only. - Snippets are plain text around the match, no markup — draw them however you like.
Presentation animation is declarative:
- Hover feedback animates itself.
hover_fill,hover_strokeandtintare transitions bound to hover state — declare the color, the fade is automatic.tintis the odd one: its number is the fade duration in milliseconds, not a color or an amount. - Press feedback is declarative for clickable elements.
press_fill/press_strokeapply while the pointer is down;press_scale = 0.96scales anon_clickelement subtree around its centre.press_scaleneeds anidand does not change layout. - Value animations go to a declared target.
fade = {target, ms}animates opacity,fade_in = msfades in on first appearance,slide_in = {{dx, dy}, ms}slides in from an offset.opacityis the static version with no tween.
Progress is kept per element id — which is why an animated element needs a stable one.
Retargeting mid-flight reverses smoothly instead of jumping: kanban's drop guides are
fade = { on and 1 or 0, 140 } on an id that never changes, so the line fades in and out
as the pointer moves. on_faded_out fires when a fade completes — the hook for removing an
element after it fades away.
Authored simulations are the deliberate exception: on_frame(e) runs Lua after each presented
frame and schedules the next while declared — so declaring it is the repaint request, and
removing it is how repainting stops. Read e.dt and e.elapsed by name; see §Handlers for the
full event. Keep transient positions and velocities in module locals, perform no document writes
per tick, avoid allocations in inner numeric loops, and remove the handler when settled. Routine
UI effects must use the declarative transitions above.
Corrected 2026-09-20: this paragraph read on_frame(dt, elapsed) — the old positional form —
while §Handlers documented the named-field event. Following it bound the wrong values silently and
kept running, which is exactly the failure §Handlers warns about. Logged as gap-log 1.3 before
being fixed, because a guide that contradicts itself is the authorability finding six-apps.md
exists to measure, and patching it in passing would have destroyed the evidence.
The kanban app is the canonical example — no other app exercises all of this. Pattern → where to look:
| pattern | file |
|---|---|
typed drags — on_drag updates S, on_drop computes placement, one commit at "release"; one drop target, two meanings (card into / column beside) |
main.lua + model.lua |
the drag ghost — rebuilt at the root, absolute + top/left in viewport coords, no id and no handlers (a duplicate id would collide), the original dims via opacity |
main.lua |
| drop guides always rendered — the space is reserved, only the alpha animates, nothing shifts when a target appears | ui/widgets.lua |
| resize — a grip in the gutter, live width from viewer state, one write on release — the full pattern below | model.lua + main.lua |
modal — a root-level absolute scrim with rgba fill and on_click to dismiss; the panel swallows clicks with on_click = function() end and appears with fade_in |
main.lua |
composer — ui.input + button, draft in ui.state("draft:" .. col_id), cleared on send |
main.lua |
spacer — ui.col({ grow = true }) pushes what follows to the far end |
throughout |
Column resizing is achieved and shipping in kanban, and it's the most instructive pattern in the corpus: all three state homes in a few lines.
-- model.lua — the drag lives in viewer state, the width lives in the document
function actions.resize(msg)
if msg.phase == "start" then
local c = find(board.columns, msg.id)
local w = c and c.w or C.col_w
S.resize = { id = msg.id, from = w, w = w }
return
end
if not (S.resize and S.resize.id == msg.id) then return end -- a stale drag
if msg.phase == "move" then
local w = S.resize.from + msg.dx
S.resize.w = math.max(C.col_w_min, math.min(C.col_w_max, w))
else -- "end"
board:set({ "columns", msg.id, "w" }, S.resize.w)
S.resize = nil
end
end
-- main.lua — this frame's width: the drag's live value, else the stored one
local function width_of(c)
if S.resize and S.resize.id == c.id then return S.resize.w end
return c.w or C.col_w
endWhat to steal from it:
- Clamp on every move, never only at the end — an end-only clamp lets the pointer walk past the limit and the column sits dead until it comes back.
- During the drag, layout reads viewer state; the document is untouched. A drag is sixty
frames of
S.resize.wand one:setat"end"— one document write per finished gesture, which is also what a future peer receives: the commit, not the drag. - The write needs no guard — an unchanged value is a no-op in the document.
min_w/max_won the element are the layout guard — a second, separate job from the drag clamp. A width can arrive that never passed the clamp: from a peer whose theme differs, from a hand-edited document. The drag clamp is policy; the bounds are physics.- The grip is a sibling in the gutter, not a child of the column — it must sit between two columns, and a child is clipped by its parent's rounded panel.
The kanban split, worth copying at any size:
theme.lua— palette and geometry constants; depends on nothing.model.lua— the document, guarded seeds, and theactionstable (the update half of Elm); shared interaction state is a field on the exported table — alocalrebind is invisible acrossrequire, a table field is not.main.lua— the view; owns everything that reads or writes pointer state, so the read and the write stay in one file.ui/widgets.lua— pure functions of their arguments; handlers are passed in, never reached for.
Your code runs sandboxed: no io, no filesystem, no network, no os — and require can
only see your own folder. Available beyond plain Lua: doc, ui, require, search, now()
(unix seconds as a float, wall clock), uuid(). A runaway loop is killed, with the line number.
now() is for recording when something happened — a created-at, a last-edited. It is not for
measuring how long something took: it follows the system clock, so it can jump, including
backwards. Anything timing a gesture or an animation wants the monotonic clock instead, which
reaches Lua as e.t on on_drag and e.elapsed on on_frame.
Errors are for reading, not for fearing:
- An unknown prop or a value of the wrong type is an error at that element — a red box in place, siblings stay alive.
- A
gfxcall that is missing a required field names it:frame needs width,fill needs brush,stroke needs width. Passing the wrong handle — a brush where a path goes — says so too:fill.path must be a gfx.path. - A view that throws keeps the last good frame with an error banner naming file and line.
- Nothing you write in a handler can take the app down for good; fix the file and it reloads.
The fastest agent authoring loop is: upload once, then use the Python bridge client's
read_file_versioned and edit_file methods for exact edits to existing source. Inspect the
rendered tree and console after activation, then repair against the new revision if needed.
WriteFile is for initial upload, file creation or an explicit wholesale replacement—not the
normal edit loop. Keep files small enough that a reported line number means one obvious thing.
An app may include tests/*.lua beside main.lua. The first built slice runs those files over the
bridge in a separate sandboxed test VM with t.expect(cond, message), t.step(frames),
t.world(), t.rects(), t.centre_of(id), t.click_at(x, y), t.text(id) and
t.type(id, text) (sets an input's text, as typing would), after opening a temporary
non-persisting app tab with empty docs. That tab gets its own in-memory search index, so an app
can test its search (demo_apps/chat/tests/search.lua). The planned behavioural runner will add
keyboard helpers while still keeping tests outside the app VM; see
docs/design/lua-app-tests.md.
The shell runs windowless: shell2 --offscreen WxH is the real shell — real layout, real pixels,
real Lua — with nowhere to present the frame. Drive it over the bridge from Python.
from osvauld.session import Session, shell_binary
with Session(shell_binary=shell_binary(), offscreen=(900, 700)) as s:
s.rpc.signup("me", "passphrase")
ws = s.rpc.create_workspace("scratch")
item = s.rpc.create_item(ws["id"], "pie", "app")["id"]
s.rpc.upload_folder(item, "demo_apps/pie")
s.rpc.open_item(item)
source = s.rpc.read_file_versioned(item, "main.lua")
result = s.rpc.edit_file(item, "main.lua", source["revision"], [
{"old_text": '"Where visits come from"', "new_text": '"Traffic sources"'},
])
assert result["persisted"] and result["activation"] == "activated"
s.rpc.dump_tree(item) # the El tree: ids and handlers, no rects
s.rpc.click(item, "legend:search") # by id — calls the handler, no hit-test
s.rpc.rects() # ['pie', 'legend:search', …] and where they are
s.rpc.click_at(*s.rpc.centre_of("legend:search")) # by coordinate, through the hit-test
s.rpc.keyboard("KeyW", "w", True) # physical code, logical key, down
s.rpc.frame(3) # hold across three driven frames
s.rpc.keyboard("KeyW", "w", False) # release (distinct from input's Key-by-id)
s.rpc.frame(250) # time passing
s.rpc.save_screenshot(item, "out.png")
s.rpc.read_console(item) # errors, newest lastOSVAULD_OFFSCREEN=900x700 makes every script in scripts/ windowless without editing it, and
python3 scripts/smoke.py runs the smokes. print() from a handler reaches read_console.
Never guess a coordinate. rects() answers with every reachable element and the rect a pointer
must land in — the clipped rect, which is what the hit-test tests, so an element scrolled half
out of view reports where it can actually be hit rather than where its layout box is. An element
that is fully clipped is absent, because it cannot be hit at any coordinate. dump_tree says what
exists; rects says what is reachable. Coordinates are logical points from the top-left, the same
units an element's rect is in.
The pointer ops report what is under the pointer afterwards, so a miss reports as a miss. This is the difference that matters: an app that did not change tells you nothing about whether you were 5pt out or 200.
Click by id and click_at by coordinate are not the same test. The first calls the handler
directly; the second goes through on_cursor_moved / click / on_cursor_release — the same
methods a window calls, with hit regions, shape hits, drag slop and eligibility all in play. Use
the id form to drive an app, the coordinate form to test that it is touchable.
Offscreen the clock is virtual and driven: nothing moves until a request asks. A frame is
1/60s and a pointer event lands 8ms after the frame it is tested against, whatever the machine
actually took — so frame(250) is a little over four seconds of app time, on every machine, every
run. advance(secs) jumps instead, then paints once so the app notices: a 25-minute timer
finishing is one request, not 90,000 frames.
Two behaviours are easier to see here than to reason about: a press that travels more than 5pt is a drag and fires no click, and a press that travels less is a click reported at the point it was released. Since no hand is perfectly still, the second is the ordinary case.
What this still cannot tell you: it needs a DISPLAY even though it shows nothing (windowless,
not headless), and a view that passes here can still look wrong — check the screenshot.
Rewritten 2026-09-20. This section taught cargo run -p app_host --example open, a second driver
that only worked from a source checkout and had no ids, no screenshots and no hot reload. The
bridge now does everything it did; see design/six-apps.md §7 for why there is one driver.