# slate32 Agent Guidelines ESP32 firmware (C++/Arduino) hosting Lua apps off an SD card. C++ owns the panel, touch, network and persistence; everything user-visible is Lua under `sdcard/`. ## Build and Test ```sh nix develop -c make test # C++ unit tests, Lua tests, and the stub check nix develop -c pio run # firmware -> .pio/build/esp32-32e/firmware.bin nix develop -c make stubs # regenerate stubs/slate32.lua after any binding change ``` `make test` fails if `stubs/slate32.lua` drifts from the bindings. Regenerate rather than hand-editing it. ## Binding Conventions Accessors are `getName` / `setName` / `isName`. Bare names are actions (`gui.fillRect`, `wifi.scan`) or pure conversions (`gui.color`, `http.urlencode`). A missing getter is fine; an accessor without a prefix is not. Namespaces have one job each: `gui` device primitives and the live frame, `node` the widget tree, `ui` widgets and palette, `settings` persisted preferences, `sys` process and runtime, plus `wifi`/`http`/`fs`/`input`/`log`. **A setter that requires a follow-up call is a bug in the setter.** `settings.setTimezone()` applies the TZ itself; `settings.setRotation()` applies the frame and re-clips. The one unavoidable exception is the theme, because C cannot reload the Lua palette — `ui.setTheme()` is the seam that pairs them, and apps call that, never `settings.setTheme()`. **Persisted intent is not live state.** `settings.getRotation()` is what the user saved; `gui.getRotation()` is the frame being drawn. They diverge on purpose while an app rotates the panel transiently (touch calibration does). Never resolve one from the other. Settings live in C++ (`src/settings.h`) because the firmware reads rotation and calibration before any `lua_State` exists, and calibration again on every touch. Lua reaches them through bindings so there is one writer. ## The Widget Tree The tree lives in `src/ui/layout.h`, not in Lua. A node is a **16 byte struct** in a flat arena; the same tree as Lua tables cost roughly forty times that, and a long list could not coexist with WiFi's buffers. `sdcard/lib/ui.lua` is a wrapper: `ui.button{...}` returns an integer handle, so a node carries nothing an app puts on it. Anything an app used to hang on a node goes in a Lua table keyed by id, which is what `on_press` itself does. The split is by lifetime, and it is the whole design. `Node` holds what hit testing and repainting need forever. `Spec` holds what only `measure`/`place` read -- requested size, pad, gap, alignment -- and is dropped by `node.dropScratch()` when layout ends. **Re-layout rebuilds from Lua** (~8 ms, which nobody notices on a rotate) rather than retaining ~20 bytes a node against it. `measure` writes the measured size into `w`/`h` and `place` overwrites the same slots, because the two are never needed at once. Style is sparse and inherited: a role unset on a node is answered by the nearest ancestor that sets it, so a node naming no colours costs zero bytes. That is what keeps `Node` at 16. Applying a palette is one `node.setStyle()` on a subtree root, which is how a dimmed region and the lit dialog above it are one call each. **What is behind a node is derived, never set.** `bg` is the background a node offers its children to draw text on; the surface an anti-aliased corner blends into is the fill of the nearest ancestor that actually paints one, or the panel. A dialog layer paints nothing, so its card blends into the dimmed content two levels up rather than into the lit palette the layer hands down — make it a style role and the scrim stops at the rounded corners. **A screen is built from scratch.** The first node created after a layout resets the tree, automatically -- an explicit reset per build entry point is a chance to forget one and grow the arena a screen at a time. Handles from the previous screen are dead; update a live one with `ui.setText(id, text)`. Layout is `-Wall -Wextra` C++ free of Arduino headers, so `test/ui_layout_test.cpp` runs it on the host through `make test-cpp`. Adding a **primitive** (a paint routine, a layout mode) means C++ and a reflash; adding **composition** (`ui.confirm`, a new card) is still Lua on the SD card. Custom painting is the seam between them: a `custom` node paints itself through the `gui` bindings. ## Apps One `lua_State` per app, closed on exit, which is why the heap returns to the same shape after every launch instead of fragmenting. `sys.launch(path, arg)` pushes the current route, `sys.replace(path, arg)` does not, and `sys.back()` pops it; history stores paths and arguments, never Lua states. Apps receive the string as `init(arg)` -- states share no memory, so a string is the whole handoff. `sys.setAppName()` retitles the bar for a screen within an app. `sdcard/lib/keyboard.lua` paints all keys in one `ui.custom` node; a node per key would add dozens of nodes and their styles while WiFi already holds buffers. Its geometry (`keyAt`, `eachKey`) takes an explicit rect and no node, which is why `test/keyboard.lua` can assert what a tap enters on the host. A repainted node clears its own box first, and a custom painter is no exception -- the number page is narrower than the letter page, and the letter page's outer keys survive otherwise. Press feedback that must not repaint the whole node is `on_down` drawing one region and `on_unpress` restoring it after the 80 ms hold. ## Touch and Drag The firmware fires `on_touch_down` / `on_touch_move` / `on_touch_up` plus the `on_touch` tap alias. `on_touch_move` filters 2px of XPT2046 jitter and nothing else; apps such as Paint consume it directly so a stroke starts at the first real pixel. Keep ordinary toolkit UIs frame-sized. If a real long-list use case appears, prefer one purpose-built painter over a component per row. ## Status Bar `sdcard/lib/statusbar.lua` paints the top strip; the firmware only clips apps out of it and calls `draw(hasBack)` on a timer. **Invalidation is entirely Lua's**: `draw()` compares each field against what it last painted and keys the whole cache on `gui.getRotation()`, `ui.themeName` and `sys.getAppName()`. Adding a field that can change means adding a term to that key, never a flag on the C++ side. A new app gets a fresh `lua_State`, so an empty cache already means "repaint everything". The one change Lua cannot observe is an app that painted over the bar while fullscreen — apps toggle through `statusbar.setFullscreen()`, which drops the cache. Do not add invalidation flags on the C++ side; the facts that drive the bar are only knowable in Lua. ## Emulator Full instructions in `.pi/skills/test-e32r40t-firmware/SKILL.md`. Two things that cost time: - The launcher logs `[lua] home ready`, not "launcher ready". It is `/apps/Home`, an app like any other. - `wait-frame` is an e-ink command and hangs on this board, and `sleep` is not a command at all -- a script using either stops silently. Use `wait-idle SECONDS` between captures and `wait-log` for everything semantic. - Captures round-trip slower than a fast UI transition, so a screen that appears for under a second is unreliable to photograph. Assert those on the host instead. Pure Lua logic belongs in `test/*.lua` against `test/fake_device.lua`, which is the single definition of the binding surface for host tests. Renaming a binding means editing that file. Reserve the emulator for the panel, touch and SD. `fake_device` fakes the tree's **structure and none of its geometry**, because geometry is asserted in `test/ui_layout_test.cpp` against the same C++ the panel runs. So a test presses a control by what it says -- `device.tap("Rotation")`, `device.labelled(prefix)` -- never by a coordinate. `device.press(id, x, y)` is for widgets with no label to aim at, like the keyboard, whose own painter decides what a point hit.