This is the full developer documentation for Mantou
# Mantou
> Native desktop apps in TypeScript, on Bun.
Mantou lets you write desktop apps in TypeScript and TSX, run them on Bun, and show UI that Mantou draws itself on the GPU. It never shows a webview. The main thread never runs app JavaScript and never waits for it. That keeps scrolling, resizing and animation smooth while the app’s JS is busy or paused for garbage collection.
```tsx
import { Index, Text, View, createSignal, defineStyle, run } from "mantou-ui";
const dot = defineStyle({ width: 28, height: 28, backgroundColor: "#f59e0b" });
function Counter() {
const [count, setCount] = createSignal(0);
return (
setCount((n) => n + 1)}>
+1{count()}{() => }
);
}
run(() => , { title: "Counter" });
```
This runs today (`examples/counter`). For something bigger, [Mantou Mail](https://github.com/dunkbing/mantou-mail) is a three-pane mail client built the same way ([Text and Mantou Mail](/progress/text/)).
## Status
**Early development.** The threading model works with a real window on Linux, macOS and Windows. Solid-style JSX, flexbox layout, text, text input, rounded corners and borders, clipping, presses, keys and the wheel work, and so do headless test instances. Images and shadows don’t exist yet. See [threading](/progress/threading/), [UI tree, layout and rendering](/progress/ui-tree/), [text and Mantou Mail](/progress/text/) and [text input](/progress/text-input/).
## Documentation
* [Overview](/design/overview/): summary, goals and non-goals, design principles.
* [Threading and architecture](/design/threading/): who owns the main thread, and how the pieces fit.
* [The native core](/design/native-core/) and [the TypeScript runtime](/design/runtime/).
* [Protocols](/design/protocols/), [rendering and input](/design/rendering/), [FFI conventions](/design/ffi/).
* [Testing](/design/testing/), [packaging](/design/packaging/), [alternatives, risks and decisions](/design/decisions/).
## For LLMs and agents
* [`/llms.txt`](/llms.txt): an index of the documentation, following [llmstxt.org](https://llmstxt.org/).
* [`/llms-full.txt`](/llms-full.txt): all documentation in one file. [`/llms-small.txt`](/llms-small.txt) is a compact version.
* Every page is also available as raw Markdown: append `.md` to its path, for example [`/design/protocols.md`](/design/protocols.md) for [`/design/protocols/`](/design/protocols/). The home page is [`/index.md`](/index.md).
# Overview
> What Mantou is, its goals and non-goals, and the design principles it builds on.
*Architecture & design*
**Status:** Draft v0.7 · 2026-11-03 (v0.7: implementation status pages · v0.2: testing design, [§12](/design/testing/#12-testing) · v0.3: named Mantou · v0.4: open questions decided, [§16](/design/decisions/#16-decisions-formerly-open-questions) · v0.5: threading spike results, [§4](/design/threading/#4-the-defining-constraint-who-owns-the-main-thread)) **Name:** Mantou (馒头), the plain Chinese steamed bun: Mantou provides a plain, unstyled base, and the app supplies the filling ([§7.3](/design/runtime/#73-public-api-sketch)). **Packages:** npm `mantou-ui` (the bare `mantou` is taken, see [Q1](/design/decisions/#16-decisions-formerly-open-questions)) · CLI `mantou` · Rust crates `mantou-*` · C ABI prefix `mt_` **Scope of this draft:** desktop (macOS, Windows, Linux). Mobile and web are out of scope for v1.
***
## 1. Summary
Mantou lets you write desktop apps in TypeScript and TSX, run them on Bun, and show UI that Mantou draws itself on the GPU. It never shows a webview. It has three parts:
* **A thin native core** in Rust, compiled to a C-ABI dynamic library and loaded with `bun:ffi`. It owns the OS event loop, the windows, the UI tree, layout, text, drawing, scrolling, animations, input methods (IME) and accessibility.
* **A TypeScript runtime** that runs the app inside a Bun `Worker`. It turns compiled TSX and fine-grained signals into a stream of tree **ops** and sends them to the core in **one FFI call per JS turn**.
* **A small bootstrap** on Bun’s main thread. It starts the Worker and then gives the main thread to the native event loop for the rest of the process’s life.
The main thread never runs app JavaScript and never waits for it. That keeps scrolling, resizing and animation smooth while the app’s JS is busy or paused for garbage collection.
## 2. Goals and non-goals
### Goals
* Cross-platform desktop apps written entirely in TypeScript/TSX, with full access to npm, Node APIs and Web APIs through Bun.
* Self-drawn UI with an app-owned look, the same on every platform. One rendering code path, built on wgpu.
* Smooth by construction: scrolling, resizing, transitions and input handling must not depend on the JS thread.
* Idle at about 0% CPU. Nothing polls; all work is driven by events.
* Fine-grained updates: no virtual-DOM diffing; a signal change sends only the ops it causes.
* Accessible from v1: screen readers on all three desktops through AccessKit.
* Ships as an app bundle or installer per platform, built from one command.
### Non-goals (v1)
* Wrapping OS widgets (NSButton, Win32 controls, GTK widgets). This is not a “native widgets” toolkit.
* iOS and Android. Bun doesn’t run there; see [§14](/design/decisions/#14-alternatives-considered) for what this rules out.
* Running in a browser.
* Pixel-identical text to each platform’s own toolkit (see [§8.5](/design/protocols/#85-text) for the trade-off).
## 3. Background and design influences
| | **Mantou** |
| -------------------- | ------------------------------------------------- |
| Who owns the process | Bun; the main thread is handed to the native loop |
| Where app code runs | Bun `Worker` (JavaScriptCore, JIT) |
| UI model | Compiled JSX + Solid signals (in JS) |
| Tree, layout, text | Rust: Taffy, parley |
| Drawing | wgpu, instanced quads |
| Web/Node APIs | Provided by Bun |
### Design principles
* **Rendering:**
* Frames are drawn only when asked for, so an idle window costs nothing.
* The scene is a flat list of operations.
* The renderer draws one instanced quad per operation with a signed-distance-field (SDF) shader for rounded rects, borders and shadows, plus glyph atlases.
* Layout results are cached and reused when an element hasn’t changed.
* **Threads and the tree:**
* The app UI never waits for JS. One JS turn produces one commit.
* The core keeps a shadow tree; JS only sends mutations.
* Text is measured off the main thread and cached by content hash plus width.
* Removing a node only detaches it; freeing it is a separate step.
* A node already in place is never re-inserted.
* During a resize, the app waits a bounded time for the new layout.
* Layout rounds to device pixels, not whole points.
# Alternatives, risks and decisions
> Alternatives considered, risks, decisions on the former open questions, references and the threading spike appendix.
## 14. Alternatives considered
| Alternative | Why not (for now) |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Native loop on the main thread, **pumped from a JS timer** | Idle CPU vs. input latency trade-off; Bun I/O stalls during waits |
| **Two processes** (native host + Bun process over shared memory) | More moving parts and IPC latency. **Kept as the fallback** if the Worker approach fails on some OS |
| **Layout in TS** (Yoga WASM), display list per frame | Text measurement crosses FFI mid-layout; scrolling and resize would depend on the JS thread |
| **Pure TS over `bun:ffi`** to AppKit/Win32/GTK, with no Rust | No structs by value; verbose Objective-C and COM; much more FFI traffic; three backends |
| **Wrap OS widgets** | Three implementations per widget; looks and behaves differently per platform |
| **Native views per node** (AppKit, GTK, Direct2D) | Scroll physics and text come native, but there are three backends to maintain. The core stays backend-agnostic at the scene level so this could be added later |
| **Embed a small JS engine** such as QuickJS instead of using Bun | Would run on mobile, with a small binary, but loses the JIT, npm and Node APIs. Bun can’t be embedded. If mobile becomes a requirement, revisit |
| **Immediate-mode rebuild per frame** | Rebuilding the tree every frame in JS creates garbage-collection pressure; fine-grained signals avoid it |
## 15. Risks
| Risk | Likelihood | Mitigation |
| ------------------------------------------------------------------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------- |
| Bun’s main thread isn’t usable as AppKit’s main thread, or Worker + FFI is unstable on macOS or Windows | Medium | Test first, with the threading spike. Fall back to two processes |
| `bun:ffi` or Worker regressions between Bun versions | Medium | Pin the Bun version; ABI smoke tests in CI on each Bun upgrade |
| IME and text editing effort | High | Budget for it generously; start from winit’s IME support; test with real CJK input methods |
| Text quality perceived as “not native” | Medium | `TextEngine` trait; screenshot comparison checkpoint once text renders; Core Text backend first if needed |
| GC timing makes the safety-net release unpredictable | Low | Deterministic release on owner disposal ([§7.4](/design/runtime/#74-node-lifetime)) |
| Binary size (\~109 MB for a compiled Bun app) and memory vs. Go or Rust frameworks | Certain | Document it; it’s the price of Bun’s ecosystem |
## 16. Decisions (formerly open questions)
1. **Q1 Name — resolved: Mantou.** The npm name `mantou` is held by an unrelated Bun project template (`bun create mantou-app`; last published 2025-02, \~135 downloads/month in Oct 2026). Use `mantou-ui` (free on npm and crates.io as of 2026-11-03), or ask the owner to transfer `mantou`. Still to check: a `@mantou` npm scope, the GitHub org, the domain, and trademarks.
2. **Q2 Text input — decided:** controlled (`value` + `onChange`, reconciled by `rev`) and uncontrolled (`defaultValue`) modes, with editing owned by the core in both, and declarative constraints instead of synchronous filters ([§8.3](/design/protocols/#83-text-input)). This matches what React and DOM developers expect without making typing wait for JS.
3. **Q3 Node release — decided:** deterministic release when the owning Solid scope is disposed, a `FinalizationRegistry` safety net, and a development-build warning when the safety net fires ([§7.4](/design/runtime/#74-node-lifetime)). Native resources such as textures and text layouts shouldn’t wait on JavaScriptCore’s GC timing.
4. **Q4 Styling — decided:** typed style objects in v1, with shared `STYLE_DEF`/`STYLE_USE` on the wire from the start, and an optional build-time Tailwind subset later that compiles into the same definitions ([§8.4](/design/protocols/#84-styles)). Type checking and a small surface come first; classes later cost no change to the core.
5. **Q5 Text engine — decided:** parley everywhere behind a `TextEngine` trait, with a screenshot comparison against native labels once text renders, and Core Text as the first native backend if needed ([§8.5](/design/protocols/#85-text)). One engine means one code path and text snapshots that hold on every OS.
6. **Q6 Reuse an existing native core — decided: no; build our own.** The candidates we looked at were large, carried their own reactive graph (we keep Solid in JS) and networking stacks, targeted native views rather than a scene for a GPU renderer, and had APIs that were still moving. Adopting one would mean adopting its architecture. We keep the ideas instead ([§3](/design/overview/#3-background-and-design-influences)): our own rounding to device pixels on top of Taffy, the measure-cache design, the `before`-anchored insert, and known Taffy pitfalls as regression tests.
7. **Q7 Hot reload — decided:** `mantou dev` restarts the Worker on save and keeps the windows open. The core clears the instance’s tree but keeps windows, surfaces, GPU resources and atlases, so nothing flashes, and the new Worker rebuilds the UI.
* State survives only if the app opts in, through `persist(key, signal)`, which stashes values in the core across restarts (development builds only).
* Component-level hot module replacement (solid-refresh style) is deferred. A fresh Worker never leaves stale module state behind, and Bun starts a Worker in milliseconds.
## 17. References
* **Solid** `solid-js/universal`, `babel-preset-solid` (`generate: "universal"`).
* **Rust crates:** winit, wgpu, taffy, parley, fontique, swash, accesskit, muda, rfd, arboard.
***
## Appendix A — Threading spike (validated on Linux, Bun 1.4.4)
```c
// native.c — a native thread that calls back into JS three times
#include
#include
typedef void (*cb_t)(int);
static cb_t cb;
static void *run(void *a) { for (int i = 1; i <= 3; i++) { usleep(300000); cb(i); } return 0; }
void start(cb_t f) { cb = f; pthread_t t; pthread_create(&t, 0, run, 0); pthread_detach(t); }
```
```ts
// main.ts — the main thread blocks in native code, as [NSApp run] would
import { dlopen, FFIType } from "bun:ffi";
const libc = dlopen("libc.so.6", { sleep: { args: [FFIType.u32], returns: FFIType.u32 } });
new Worker(new URL("./worker.ts", import.meta.url).href);
await Bun.sleep(50);
libc.symbols.sleep(2);
```
```ts
// worker.ts — timers, async I/O and native wake-ups keep working
import { dlopen, JSCallback, FFIType } from "bun:ffi";
const lib = dlopen(import.meta.dir + "/libnative.so", { start: { args: [FFIType.function], returns: FFIType.void } });
const cb = new JSCallback((n: number) => console.log("native event", n),
{ args: ["i32"], returns: "void", threadsafe: true });
lib.symbols.start(cb.ptr);
setInterval(() => console.log("tick"), 100);
console.log((await Bun.file(import.meta.path).text()).length);
```
Observed while the main thread was blocked for 2 s: the 100 ms timer kept time, the file read completed, and the native thread’s three callbacks ran at 300 ms intervals.
# FFI conventions
> Rules for calls, pointer lifetimes, callbacks and version checks across bun:ffi.
## 11. FFI conventions and Bun-specific rules
* **Pass scalars, pointers and lengths only.** Encode everything else into buffers.
* **Keep the call count low:** one call per commit and one per batch of events. Never one call per node or style.
* **Pointer lifetimes:**
* Op buffers belong to JS and must stay alive for the length of the `mt_commit` call. The core copies anything it keeps.
* The pointer returned by `mt_take_events` is valid until the next call.
* **Callbacks:** only the `wake` callback crosses from native to JS, and it is `threadsafe: true`. No other native-to-JS callbacks exist.
* **Version checks:** `mt_abi_version()` is checked at startup, and the op format carries `version`. A mismatch is a fatal error with a clear message.
* **Experimental APIs:** Bun marks `bun:ffi` and `Worker` experimental. Pin the Bun version per release and run CI on all three OSes.
# The native core
> The Rust core: its crates, code layout and C ABI.
## 6. The native core (Rust)
### 6.1 Crates
| Concern | Crate | Notes |
| ------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------ |
| Windows, input, IME, event loop | `winit` | `run_app` on the main thread; `EventLoopProxy` for wake-ups from other threads |
| GPU | `wgpu` | Metal / D3D12 / Vulkan, with GL as a fallback; WGSL shaders |
| Layout | `taffy` | flexbox, grid, block. Do our own rounding to device pixels |
| Text layout and shaping | `parley` + `fontique` | Bidi, line breaking, font fallback. `cosmic-text` is the alternative |
| Glyph rasterization | `swash` | Coverage + color (emoji) atlases |
| Accessibility | `accesskit` + `accesskit_winit` | VoiceOver, Narrator/UIA, Orca/AT-SPI |
| Menus, tray | `muda`, `tray-icon` | |
| Dialogs, clipboard | `rfd`, `arboard` | |
| Images | `image` (PNG/JPEG/WebP), `resvg` (SVG) | Decoded off the main thread |
### 6.2 Layout of the core’s code
```plaintext
core/
crates/
mantou-ffi C ABI surface (cdylib); the only crate with `extern "C"`
mantou-tree shadow tree, op decoder, styles, Taffy integration, hit-testing, focus
mantou-text parley wrapper, measure cache, glyph atlas management
mantou-scene immutable scene / display list built from the tree after layout
mantou-render wgpu renderer: instanced SDF quads, glyphs, images, clips, layers
mantou-platform winit app, windows, IME, AccessKit, menus, dialogs, clipboard
```
`mantou-tree`, `mantou-text` and `mantou-scene` have no platform dependencies, so they can be tested headless ([§12](/design/testing/#12-testing)).
**Status:** `mantou-ffi`, `mantou-tree` and `mantou-render` exist. For now the scene types live in `mantou-tree`, and the winit code in `mantou-ffi`; `mantou-scene`, `mantou-text` and `mantou-platform` split out when text and platform services arrive. The C ABI is at version 3: every per-UI function takes an instance handle, and headless instances exist for tests ([§12.2](/design/testing/#122-headless-mode-core-support)).
### 6.3 C ABI
`bun:ffi` can’t pass structs by value. Every call therefore takes only scalars, pointers and lengths.
```c
uint32_t mt_abi_version(void);
// Bootstrap (main thread)
void mt_run(const uint8_t* config, uint32_t len); // blocks until quit; returns exit code via mt_exit_code()
int32_t mt_exit_code(void);
// Worker thread
void mt_set_wake(void (*wake)(void)); // threadsafe JSCallback pointer
void mt_commit(const uint8_t* ops, uint32_t len); // apply ops, layout, build scene, request redraw
uint32_t mt_take_events(const uint8_t** out); // swaps the event buffer; *out valid until the next call
uint32_t mt_measure_text(const uint8_t* req, uint32_t len, uint8_t* out, uint32_t cap); // optional, for app-side measuring
void mt_quit(int32_t code);
```
Both threads `dlopen` the same library. A commit made before `mt_run` has started is queued.
**Revision planned (see [§12.2](/design/testing/#122-headless-mode-core-support)):** the core becomes multi-instance. Every function will take an instance handle: `mt_run` creates the app instance, and `mt_headless_new` creates headless instances for tests. The signatures above leave the handle out for brevity.
# Packaging
> How Mantou apps are packaged and distributed on macOS, Windows and Linux.
## 13. Packaging and distribution
* `bun build --compile ./main.ts ./app-worker.ts` produces a single executable containing both the bootstrap and the Worker entry. Verified with Bun 1.4.4 on Linux: the Worker entry must be listed as an extra entry point, and `bun:ffi` works inside the compiled Worker.
* The native core: `dlopen` needs a real file. Ship it beside the binary inside the bundle, or embed it and extract it to a cache directory named by a content hash on first run.
* **macOS:**
* `.app` bundle, plus a `.dmg`.
* Code signing with the hardened runtime. JavaScriptCore’s JIT requires the `com.apple.security.cs.allow-jit` entitlement.
* Notarization.
* **Windows:** an installer (MSI or NSIS), signed with Authenticode.
* **Linux:** AppImage and `.deb`. Runtime dependencies are few: Vulkan or GL drivers, and the libraries winit needs (Wayland/X11, xkbcommon).
* **Size:** dominated by the Bun runtime. A minimal compiled bootstrap + Worker measured about 109 MB (104 MiB) on Linux x64 with Bun 1.4.4, before the native core. Track it in CI, and check whether compression in the installers (DMG, MSI or NSIS, AppImage) brings the download down substantially.
# Protocols
> The op stream, event records, text input, styles and text handling between the Worker and the core.
## 8. Protocols
### 8.1 Ops (Worker → core)
One `ArrayBuffer` per commit. It holds a header, a section of newly interned strings, and the op stream as 32-bit words. `f32` values are written through a `Float32Array` view over the same buffer.
```plaintext
header: magic u32 · version u16 · flags u16 · opCount u32 · stringsBytes u32
strings: repeated { id u32 · byteLen u32 · utf8 bytes (padded to 4) } // new interned strings only
ops: repeated { opcode u16 · wordCount u16 · words… }
```
**Status:** the op stream is implemented as above, with each op’s first word `code | operandWords << 16`. The header and the string section aren’t yet: strings travel inline, as a byte length and then the UTF-8 bytes padded to words. The source of truth is [`schema/protocol.ts`](https://github.com/dunkbing/mantou/blob/main/schema/protocol.ts); `bun run gen` generates the TypeScript and Rust bindings from it, and CI fails when they’re stale. The ops implemented so far:
* **Tree:** `CREATE` (kinds `View`, `Text` and `Input`), `INSERT`, `REMOVE`, `RELEASE`, `STYLE`, `STYLE_RESET`, `STYLE_DEF`, `STYLE_USE`, `LISTEN`.
* **Content:** `TEXT` (a Text node’s content), and for inputs `VALUE` (id, rev, text), `PLACEHOLDER`, `INPUT_OPTIONS` (flags: multiline, read-only; the maximum length) and `FOCUS` (0 blurs), in place of the `PROP` and `COMMAND` ops below.
* **Window:** `SET_ROOT`, `CLEAR_COLOR`, `TITLE`.
| Op | Operands | Meaning |
| ------------- | ---------------------------------- | ---------------------------------------------------------------------------------- |
| `CREATE` | id, kind | New node (`View`, `Text`, `Span`, `Scroll`, `Image`, `Input`, `List`, `Window`…) |
| `TEXT` | id, strId | Text content of a text leaf or span |
| `INSERT` | parent, child, before (0 = append) | Insert, or move if the child is already attached |
| `REMOVE` | parent, child | Detach only |
| `RELEASE` | id | Free the node and its resources |
| `STYLE` | id, prop, value… | Set one style property (typed value; see below) |
| `STYLE_RESET` | id, prop | Back to the default |
| `PROP` | id, prop, value… | Non-style props: `src`, `placeholder`, `role`, `label`, `value`+`rev`, `disabled`… |
| `LISTEN` | id, eventMask | Which events this node has handlers for |
| `STYLE_DEF` | styleId, (prop, value…)\* | Define a shared style ([§8.4](/design/protocols/#84-styles)) |
| `STYLE_USE` | id, styleId (0 = none) | Apply a shared style; per-node `STYLE` ops override it |
| `COMMAND` | id, cmd, reqId, args… | `focus`, `blur`, `scrollTo`, `measure`, `animate`, `select`… |
**Value encodings:**
* `f32` (number)
* `u32` (enum)
* `color` (RGBA8 packed into a u32; wide gamut later)
* `length` (f32 + a unit: px, %, auto)
* `strId`
* short lists: a count followed by the items
Style and prop ids, and their types, are **generated from one schema** shared by the Rust decoder and the TS encoder and typings, so the two sides can’t drift apart.
### 8.2 Events (core → Worker)
* The main thread appends event records to a buffer guarded by a mutex. It sets an atomic `wake_pending` flag, and only if that flag was clear does it call the threadsafe `wake` callback. That coalesces bursts into one wake.
* On wake, the Worker calls `mt_take_events()`, which swaps the buffers and clears the flag. It then dispatches every record. Any ops the handlers produce flush in the same turn’s microtask.
* Record format: `type u16 · size u16 · target u32 · time f64 · payload…`
* **Status:** records are `[head, a, b, c]` as u32 words (floats as bits) and then `head >> 16` more words; `head & 0xffff` is the type. Only `TEXT_CHANGED` has more words so far: its text. `mt_take_events` copies whole records and returns 0 when none are left, or `0x80000000 | size` when the next record doesn’t fit, and the runtime grows its buffer. Implemented: `WINDOW_READY`, `RESIZED`, `SCALE_CHANGED`, `CLOSE_REQUESTED`, `POINTER_DOWN/MOVE/UP`, `KEY_DOWN/UP` (with modifiers), `PRESS`, `WHEEL`, `FOCUS`, `BLUR`, `TEXT_CHANGED` and `SUBMIT`.
| Event | Payload |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `PRESS`, `LONG_PRESS` | position; the core synthesises these from the pointer, keys and accessibility actions |
| `POINTER_DOWN/MOVE/UP/ENTER/LEAVE` | position, buttons, modifiers. Sent only to nodes listening for them |
| `KEY_DOWN/UP` | key, code, modifiers, repeat |
| `TEXT_CHANGE` | value strId, selection, `rev` |
| `FOCUS`, `BLUR` | — |
| `SCROLL` | offset (throttled; only if listened for) |
| `LAYOUT` | rect. For `onLayout` listeners and `measure` replies (`reqId`) |
| `WINDOW` | resized, moved, close-requested, focus changed, scale changed |
| `MENU`, `A11Y_ACTION`, `FILE_DROP`, `COMMAND_RESULT` | … |
**Propagation:** the core hit-tests and sends the target, plus the path of ancestors that have listeners. JS runs the capture and bubble phases. `stopPropagation` happens in JS. **No synchronous `preventDefault`:** input is asynchronous, so the core’s default actions (focus on press, scroll on wheel, editing keys) are controlled by props (`focusable`, `scrollEnabled`, `onKeyDown` with declared `keys` it consumes). This is a deliberate API constraint.
### 8.3 Text input
* **The core owns editing:** caret, selection, IME composition, undo, clipboard and word movement, so typing never waits for JS.
* **Decided (Q2): two modes, like the DOM. Typing never waits for JS in either.**
* `defaultValue` makes the input **uncontrolled**: the core holds the text, and `onChange` reports it.
* `value` + `onChange` makes it **controlled**: the core applies keystrokes at once and reports `TEXT_CHANGE(value, rev)`. The `value` prop JS sends back carries the `rev` it answers, and the core ignores values whose `rev` is stale. A `value` that differs from what the user typed (uppercasing, say) replaces the text, and the caret is kept by its offset from the end.
* **Constraints are declarative, not synchronous filters:** `maxLength`, `allow` (a regex over characters, checked natively), `inputMode`, `autoCapitalize`, `secure`. These cover what `beforeinput` filtering is usually for, without a synchronous round trip through JS.
* **Status:** implemented as `` ([Text input](/progress/text-input/)), with `maxLength` and `readOnly` of the constraints. The editor is parley’s `PlainEditor` with Mantou’s undo, limits and scrolling (`mantou-text/src/editor.rs`); focus, keys, the pointer and the IME are in `mantou-tree`, and the clipboard is the FFI crate’s. A controlled value that replaces the user’s text keeps the caret at its byte offset, clamped, rather than its offset from the end. The keys the focused input uses aren’t sent to JS at all, which settles the `onKeyDown` question of [§8.2](/design/protocols/#82-events-core--worker) for inputs.
### 8.4 Styles
* **Decided (Q4): v1 uses typed style objects only** (`style={{…}}`). They cover flexbox and grid (Taffy), box properties (background, border with per-side widths, radii, shadow, opacity, transform, overflow/clip) and text (font family, size, weight, line height, color, alignment, decoration). TypeScript checks every property.
* **Shared styles on the wire from day one:**
* The JSX plugin hoists static style objects. The runtime sends each one once as `STYLE_DEF(styleId, props…)`, and nodes reference it with `STYLE_USE(id, styleId)`.
* Dynamic values still go through per-property `STYLE` ops, and they override the shared style.
* A list of 1,000 rows sharing one style therefore costs one definition plus 1,000 four-byte references.
* **Later (once the base components settle):** an optional build-time Tailwind subset that compiles `class="…"` into the same `STYLE_DEF`s, so the core never parses classes. It’s worth adding for AI-generated UI, but not before theming and the base components settle.
* Units are logical pixels. The core rounds layout to device pixels at the window’s scale factor.
### 8.5 Text
* parley lays text out on the Worker thread, inside `mt_commit` during layout. Measurements are cached by a hash of the layout-relevant content plus the width constraint; colour is excluded, so a colour change never triggers layout.
* swash rasterizes glyphs into a coverage atlas and a colour atlas. A glyph seen for the first time goes into a transient zone and moves to a lasting one once drawn again. If an atlas fills mid-frame, the core makes room and repaints rather than showing missing glyphs.
* **Status:** implemented in `mantou-text`. The bundled font is Inter (Regular, Medium, SemiBold, Bold; OFL), with the system’s fonts as the fallback. Layouts are cached by text, style and width, and the parley font context is built once per process. Glyphs are rasterized at device size with four horizontal subpixel positions into one coverage atlas, so colour emoji draw as monochrome shapes for now. `maxLines` cuts off with an ellipsis. Text styles aren’t inherited yet, and a `` has one style (no spans).
* **Decided (Q5): parley on every platform in v1, behind a `TextEngine` trait**.
* **Why:** one implementation to build and debug. Layout is also identical on every OS when the fonts are the same: tests bundle a test font, so headless text and image snapshots taken on Linux CI hold everywhere.
* **Cost:** text won’t match each OS’s own toolkit pixel for pixel, as a system text stack would.
* **Checkpoint once text renders:** compare screenshots of parley text against native labels on each OS. If the difference is noticeable, add a Core Text backend first, since Mac users notice text rendering most. The trait keeps that change local to `mantou-text`.
# Rendering and input
> The frame pipeline, scene, wgpu renderer, scrolling and animation, plus input, focus, accessibility and platform services.
## 9. Rendering
### 9.1 Frame pipeline
1. **Commit (Worker thread, inside `mt_commit`):** apply ops → mark dirty → incremental Taffy layout → text layout → rebuild the scene for dirty subtrees → publish `Arc` → `EventLoopProxy` sends “scene ready” → winit `request_redraw`.
2. **Composite (main thread, on `RedrawRequested`):** take the latest scene, apply the native scroll offsets and the running compositor animations, build instance buffers, draw, present.
3. **Resize (main thread):** lock the tree, lay out at the new size (bounded wait), rebuild the scene and draw. JS is involved only if components read the window size.
### 9.2 The scene
* A flat list of operations in device pixels: rounded rect (fill, gradient, per-side border, dashes), shadow (outer and inset), glyph run, image, push/pop clip (rounded), and later effect and canvas.
* Layers: scroll containers and nodes with a running transform or opacity animation form a layer. Its transform is applied at composite time, so scrolling and these animations need no relayout and no scene rebuild.
### 9.3 Renderer (wgpu)
**Status:** one pipeline draws every quad in paint order: filled rectangles with rounded corners and borders (an SDF in the fragment shader), and glyphs from the atlas. Each quad carries a clip rectangle (`overflow: hidden`, intersected down the tree). Shadows, gradients, images and rounded clips aren’t built yet.
* One instanced quad per operation. An “uber” fragment shader computes the SDF for rounded rects, analytic blurred shadows (Evan Wallace’s method), gradients, border dashes, atlas coverage and the innermost rounded clip. Outer clips become scissor rects.
* Batches share a scissor rect and a texture. Operations completely hidden by a later opaque fill are dropped.
* Frames are drawn only when asked for: something changed, a scroll or animation is running, or the window resized. With nothing pending, the display link stays paused.
* **Later:** damage tracking, a CPU fallback (tiny-skia) for machines without a usable GPU, a canvas API.
### 9.4 Scrolling and animation
* **Scrolling** belongs to the core: wheel and trackpad input, momentum (taken from the OS where it provides it), rubber-banding and scrollbars. JS receives throttled `SCROLL` events only if it listens for them.
* **Virtualized lists:** the core asks JS for rows by index as they come into view (`LIST_NEED(rows)`), and JS answers with ops. Rows that leave the view are recycled, with new item signals bound to them.
* **Animations:**
* Declarative transitions on style props (`transition={{ opacity: 200 }}`) and an `animate()` command run in the core, with timing curves and springs.
* Animations of compositor props (transform, opacity, colour) run on the main thread without relayout.
* Animations of layout props relayout once per frame under the lock.
## 10. Input, focus, accessibility, platform services
* **Hit-testing and focus:** the core, on the main thread, against the last layout. Tab order comes from tree order plus `tabIndex`. Focus rings are drawn by the core.
* **IME:** winit’s `Ime` events plus `set_ime_cursor_area`, from the caret of the focused input. The composition is drawn inline.
* **Accessibility:** the core builds an AccessKit tree from the shadow tree: roles and labels from props, values, ranges and states. It updates it after each commit when assistive technology is active. Actions are performed natively where possible (focus, scroll, set a value); otherwise they arrive as `A11Y_ACTION` events.
* **Platform services:** menus (app, context, tray), file dialogs, the clipboard, notifications, multiple windows and DPI changes, exposed as async TS functions that go through `COMMAND` / `COMMAND_RESULT`.
# The TypeScript runtime
> The bootstrap, Solid-based reactivity, compiled JSX, the public API sketch and node lifetime.
## 7. The TypeScript runtime
### 7.1 Bootstrap (generated by the CLI)
```ts
// main.ts: runs on Bun's main thread
import { dlopen, FFIType } from "bun:ffi";
const core = dlopen(libPath(), { mt_run: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.void },
mt_exit_code: { args: [], returns: FFIType.i32 } });
new Worker(new URL("./app-worker.ts", import.meta.url).href); // the app
core.symbols.mt_run(configPtr, configLen); // never returns until quit
process.exit(core.symbols.mt_exit_code());
```
### 7.2 Components, reactivity, compiled JSX
* **Reactivity:** Solid’s signals in plain JS (`signal`, `computed`, `effect`), re-exported from `mantou-ui`. JavaScriptCore’s JIT makes this fast, so there’s no reason to move the reactive graph into native code.
* **JSX:** Bun’s built-in transform is React-style (`jsx()` calls), so it doesn’t fit. A `Bun.plugin` runs `babel-preset-solid` with `generate: "universal"` and `moduleName` pointing at our renderer. The plugin runs in `dev`, in `build` and in the test runner. A faster Oxc-based compiler can come later.
* **Renderer:** `solid-js/universal`’s `createRenderer`, implemented over a **JS mirror tree**. Solid’s renderer API needs synchronous `getParentNode`, `getFirstChild` and `getNextSibling`. Each mirror node holds an id, a parent, its children and its handlers. Every mutation updates the mirror and appends an op.
**Status:** implemented as described, with three findings.
* **Bun resolves `solid-js` to Solid’s server build.** The package’s `node` export condition points at `dist/server.js`, whose effects never re-run. Bun’s runtime plugins can’t redirect bare imports (`onResolve` never sees them), but `onLoad` does run for files in `node_modules`. So Mantou imports the client build by path (`runtime/solid/solid.ts`), and the plugin rewrites `solid-js` imports in Solid’s own `universal` and `store` builds and in app `.tsx` files. Apps import Solid’s API from `mantou-ui`.
* **Text leaves.** Solid uses empty text nodes as placeholders, so the mirror tree has text leaves (`TextLeaf`). They send no ops until the core has text; a node inserted next to one is anchored on the next real node.
* **Where it runs.** The Worker’s entry (`runtime/worker.ts`) registers the plugin before anything that uses Solid loads, then imports the app; `bun test` preloads it (`bunfig.toml`). Babel adds about 30 MB to the Worker and compiles at start-up: the counter’s first frame appears about 150 ms after launch on Linux. Compiling ahead of time for release builds is still to do.
### 7.3 Public API sketch
```tsx
import { run, Window, View, Text, Button, Scroll, signal } from "mantou-ui";
function Counter() {
const n = signal(0);
return (
{n()}
);
}
run(() => (
));
```
**Primitives (v1):** `Window`, `View`, `Text` (with nested spans), `Scroll`, `Image`, `Input`/`TextArea`, `List` (virtualized), `Canvas` (later). **Unstyled behaviour components (v1):** `Button`, `Toggle`, `Checkbox`, `Slider`, `Menu`, `Popover`, `Dialog`, `Tooltip`. They come with their behaviour, focus handling and accessibility, and the app supplies the look.
### 7.4 Node lifetime
* JS allocates node ids from a counter with a free list.
* `REMOVE` only detaches a node, because Solid memos can put removed nodes back. `RELEASE` frees the node and its native resources.
* **Decided (Q3):** a node is released when the Solid owner that created it is disposed (`onCleanup` registered in `createElement`). A `FinalizationRegistry` acts only as a safety net.
* Development builds count live nodes and warn when the safety net releases a node, because that means a release path was missed.
* Inserting a node whose owner has been disposed is an error in development builds and a no-op in production.
* **Status:** release on owner disposal is implemented (`runtime/solid/renderer.ts`); the `FinalizationRegistry` safety net and the development-build checks aren’t yet.
* Nodes aren’t freed through GC finalizers alone, because the timing of JavaScriptCore’s garbage collection is unpredictable, and native resources (GPU textures, text layouts) would linger.
# Testing
> How Mantou and apps built with it are tested: headless mode, the app test API, window mode, CI and benchmarks.
## 12. Testing
Testing covers two things:
1. **Mantou itself.** Every layer, from the op decoder to signed packages.
2. **Apps built with it.** The test API is part of the product. Mantou uses it to test itself, so it gets exercised constantly.
### 12.1 Principles
* **Never sleep and then check.** Wait for the condition or event itself (`waitFor`). A global guard of about a minute only turns a hang into a failure that names what never happened. Timeouts are never tuned to make a test pass.
* **Time is controlled.** Headless apps run on a manual clock that moves only when the test says so (`app.clock.advance(ms)`). Animations, debounces, double-clicks, long presses and Mantou’s `after()` timers all read it. No test depends on how fast the machine is.
* **JS side:** `open()` turns on `bun:test`’s fake timers. Verified in Bun 1.4.4: they cover `setTimeout`, `setInterval`, `Date` and `performance.now`.
* **Native side:** the core’s clock is advanced through `mt_test_step`.
* `app.clock.advance(ms)` moves both together, so JS timers and native animations stay in step. Bun has no `requestAnimationFrame`, so Mantou’s animation-frame API reads the core’s clock.
* **Every step settles.** Each action (`press`, `type`, `scroll`, …) runs until the app is idle at the current clock time: no queued ops, events or due timers, and no request in flight.
* **Measure counts in tests, time in benchmarks.** Tests assert ops per update, FFI calls per turn, layouts, text measurements, frames drawn and wake-ups. Wall-clock time is measured only by benchmarks, which are tracked over time ([§12.7](/design/testing/#127-benchmarks-tracked-not-gating)).
* **Query the way users and assistive technology see the app.** Find elements by role and accessible name, or by visible text, before falling back to test ids. A control a test can’t find by role probably can’t be found by a screen reader either.
* **Use one test API in two modes.** The same test file runs headless (fast, in-process, the default) or against a real window (`--target window`), through one driver interface.
### 12.2 Headless mode (core support)
The core runs without winit or a GPU surface. The tree, layout, text, scene building, hit-testing, focus, scrolling, animation and the AccessKit tree are all platform-independent ([§6.2](/design/native-core/#62-layout-of-the-cores-code)), so only the platform layer is swapped out.
This makes the core **multi-instance**: every ABI function takes an instance handle, and there are no global singletons. This also enables embedding several surfaces in one app. [§6.3](/design/native-core/#63-c-abi) changes accordingly: `mt_run` creates the app instance, and the functions below create headless ones.
**As built (C ABI version 3).** Handles are `u32`, every per-UI function (`mt_commit`, `mt_take_events`, `mt_stats`, `mt_set_wake`) takes one, and the window is the instance `mt_app()` returns. The design’s buffer-based `mt_test_input` and `mt_test_query` became scalar calls for now; they grow when keys, text and accessibility queries arrive.
```c
uint32_t mt_headless_new(uint32_t width_px, uint32_t height_px, float scale);
void mt_headless_free(uint32_t inst);
void mt_test_input(uint32_t inst, uint32_t kind, float x, float y); // pointer down / up / move, logical px
void mt_test_viewport(uint32_t inst, uint32_t w_px, uint32_t h_px, float scale);
uint32_t mt_test_step(uint32_t inst, double advance_ms); // advance the manual clock; bit 0 = events waiting, bit 1 = layout ran
uint32_t mt_test_hit(uint32_t inst, float x, float y); // topmost node, or 0
int32_t mt_test_node_rect(uint32_t inst, uint32_t id, float* out4); // laid-out box, logical px
uint32_t mt_test_scene(uint32_t inst, uint8_t* out, uint32_t cap); // text dump of the scene, for snapshots
int32_t mt_test_capture(uint32_t inst, uint8_t* rgba, uint32_t cap); // offscreen render (wgpu, software adapter OK)
uint32_t mt_stats(uint32_t inst, uint64_t* out, uint32_t cap); // counters: frames, commits, wakes, events, resizes, …
```
`runtime/headless.ts` wraps them as `HeadlessCore` (`step`, `press`, `pointer`, `key`, `wheel`, `type`, `insertText`, `compose`, `clipboard`, `value`, `focused`, `rect`, `hit`, `resize`, `scene`, `capture`, `stats`), which the app test API of §12.3 will build on.
**No Worker in headless mode.** There’s no native event loop to own the main thread, so the app runs on the test’s own thread. `mt_commit` is synchronous, and events are collected with `mt_take_events` right after each `mt_test_input` and `mt_test_step`. Tests are therefore deterministic and single-threaded.
The real threading model is covered by the window-mode tests ([§12.5](/design/testing/#125-window-mode-the-real-threading-model-and-platform-layer)).
### 12.3 The app test API (`mantou-ui/test`)
It’s built on **`bun:test`**: Jest-compatible, with `describe`, hooks, `expect.extend`, snapshots and `--coverage` included. We don’t have to write our own test runner, matchers or value equality.
```tsx
import { test, expect } from "bun:test";
import { open } from "mantou-ui/test";
import { App } from "./App";
test("adds a todo", async () => {
const app = await open(, {
size: [800, 600], scale: 2, colorScheme: "dark", locale: "en-US",
fetch: { "GET https://api.test/quote": { json: { text: "Hi" } } }, // stubbed; unmatched requests fail the test
storage: { todos: '["Walk"]' },
});
await app.waitFor({ text: "Hi" });
await app.type({ role: "textbox", name: "What to do?" }, "Buy milk\n");
await app.press({ role: "button", name: "Add" });
expect(app.texts()).toContain("2 left");
expect(app.get({ role: "textbox", name: "What to do?" }).value).toBe("");
expect(app.lastStep.ops).toBeLessThanOrEqual(6); // granularity check
expect(await app.capture()).toMatchImage("todo-added"); // custom matcher, tolerance per pixel
});
```
| Area | API |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Open | `open()` or `open("./App.tsx")`; options: size, scale, colour scheme, locale, reduced motion, platform to emulate (`"macos"` key bindings etc.), fonts, `fetch` routes, storage |
| Query | `get(q)`, `getAll(q)`, `find(q)`, `shows(q)`, `texts()`, `focused()`. `q` = `{ role, name }` \| `{ text }` \| `{ label }` \| `{ testId }` |
| Act | `press`, `longPress`, `hover`, `type` (text + `\n`), `key("Mod+S")`, `compose` (IME: marked text, then commit), `scroll`, `drag`, `dropFiles`, `resize`, `setColorScheme`, `a11yAction` |
| Wait / time | `waitFor(q \| fn)`; `clock.advance(ms)`; `clock.runAll()` (until no timers or animations are pending) |
| Inspect | `capture()` → RGBA, `scene()` → text dump, `a11yTree()`, `lastStep` / `stats()` (ops, layouts, measures, frames), `requests()` (stubbed fetches made) |
| Matchers | `toMatchImage(name, { maxDiff })`, `toHaveRole`, `toBeFocused`, `toBeVisible`; plain values use Bun’s `toEqual`/`toMatchSnapshot` |
**Network:** the app runs in the test process, so `fetch` routes swap `globalThis.fetch` for the length of the test and restore it afterwards. Unmatched requests fail with a clear error unless `fetch: "live"` is given.
**Failure output:** the step that failed, the query, the texts on screen and a compact accessibility tree. With `--trace`, also every step’s ops and commit timings.
**`mantou test ` without a test file:** runs the app headless and replays steps from stdin. It’s for quick checks and bug reports.
### 12.4 Testing Mantou, layer by layer
| Layer | Tool | What it checks |
| ------------------------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Op schema and codegen** | `cargo test` + `bun test` | TS encoder → Rust decoder round trip for every op and value type. The generated code matches committed golden files. Version and ABI mismatches fail with the intended message |
| **Op decoder** | `cargo fuzz` | Malformed or truncated buffers never panic or read out of bounds; they return errors. Random *valid* op sequences keep tree invariants: one parent per child, no cycles, released ids never reused before `RELEASE` |
| **Tree and layout** | `cargo test`, property tests (`proptest`) | Incremental layout equals layout from scratch after random mutations, frame after frame. Rounding to device pixels at scales 1, 1.25, 1.5, 2 and 2.625. Known Taffy pitfalls as regression tests |
| **Text** | `cargo test` | Measure cache: a colour change means no relayout and a width change hits the cache. Bidi, wrapping, ellipsis, emoji, font fallback. Caret and selection geometry. Atlas full mid-frame → room is made and the frame repainted, with no missing glyphs |
| **Scene builder** | `cargo test` + text snapshots | Scene dumps for reference trees. Clipping, layering of scroll containers and animations, operations hidden under an opaque fill dropped |
| **Input and focus** | `cargo test` | Hit-testing with transforms and clips, tab order, pointer capture, press synthesis, wheel and momentum scrolling on the manual clock, text editing and the IME composition state machine |
| **Accessibility** | `cargo test` | AccessKit tree for reference trees: roles, names, values, states, actions, and updates after commits |
| **Renderer** | `cargo test` (offscreen wgpu) | Reference scenes drawn offscreen and compared with committed images within a per-pixel tolerance. Each backend has its own images where they legitimately differ. Once the CPU fallback exists, it becomes the oracle that GPU output is compared against |
| **TS runtime** | `bun test` with a **mock core** that records ops | Op streams per primitive and component. Granularity: one signal update → expected op count. ``/`` keep siblings and moves never re-insert. Nodes are released when their owner is disposed (live-node count returns to its baseline). Event dispatch: capture and bubble, `stopPropagation`. Text-input `rev` handling |
| **JSX plugin** | `bun test` | Compile output snapshots: static vs dynamic props, spreads, refs, components, and source maps pointing back at the TSX |
| **Full stack, headless** | `bun test` + `mantou-ui/test` | Every example app and the component gallery: behaviour, accessibility queries, image snapshots, op-count budgets |
### 12.5 Window mode: the real threading model and platform layer
The same test files run with `mantou test --target window`. Each `open` starts the app as a **separate process** with a real window. The test process drives it over a local socket through a test-only automation protocol, using the same `Driver` interface as headless mode. Steps settle the same way: the app reports idle.
On top of the shared app tests, a **platform suite** covers what headless mode can’t:
* **Threading (the claims the design depends on, kept as regression tests):**
* While the Worker runs a 2-second busy loop, a scripted scroll still advances frames. Asserted by count: the frames presented during the block are at least the expected number for the display’s refresh rate, minus slack.
* A live resize while the Worker has `fetch` and timers in flight: both complete, and the window presents frames at its new size.
* Idle: over a 5-second window with nothing changing, **0 frames presented and 0 Worker wake-ups** (counters from `mt_test_stats`, not CPU percentages).
* The bounded resize wait never exceeds its limit; this is a counter of waits that hit the limit.
* **Platform integration:** window lifecycle and close requests, multiple windows, DPI changes (where the CI runner can simulate them), menus and their actions, clipboard round trip, file drop, dialogs (driven through the automation protocol’s dialog stubs).
* **IME:** the composition path is driven by synthetic IME events in the platform layer. The real input methods (macOS Japanese/Pinyin, Microsoft IME, ibus/fcitx) are covered by a **manual release checklist**, because CI can’t drive them reliably.
* **Accessibility smoke tests:** read the window through each OS’s own API (AX on macOS, UI Automation on Windows, AT-SPI on Linux) from a helper process. Check that a button’s role and name arrive and that a press action reaches JS.
### 12.6 CI matrix
| Job | Runner | Runs |
| ------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `core` | Linux, macOS, Windows | `cargo test`, `cargo clippy`; fuzzing nightly (time-boxed) |
| `runtime` | Linux | `bun test` (mock core, JSX plugin, schema round trip), `tsc --noEmit` on the typings and examples |
| `headless` | Linux, macOS, Windows | `mantou test` on the examples and gallery, with image snapshots. Software GPU adapters: Mesa (lavapipe/llvmpipe) on Linux, WARP on Windows; Metal on GitHub’s `macos-14` Apple Silicon runners, where wgpu’s own CI runs its GPU and image tests |
| `window` | Linux (Xvfb and a headless Wayland compositor), macOS, Windows | `mantou test --target window` + the platform suite ([§12.5](/design/testing/#125-window-mode-the-real-threading-model-and-platform-layer)) |
| `package` | each OS | Build, sign (test certificates on PRs), launch the packaged app with `--self-test` (first frame drawn → exit 0), `codesign --verify` / `signtool verify` |
| `bun-canary` | Linux, macOS, Windows, nightly | All of the above against Bun’s canary build, to catch `bun:ffi` and `Worker` regressions before they ship |
| `bench` | dedicated machines, main branch | [§12.7](/design/testing/#127-benchmarks-tracked-not-gating), results stored and compared |
**Image comparison (decided, T2):**
* Pixels are compared pixelmatch-style: a colour-distance threshold (default 0.1), with anti-aliased edge pixels detected and ignored. The test fails if more pixels differ than `maxDiffPixels`, which defaults to 0; tests can override both values.
* Reference images are stored per backend and OS only where the backends really differ.
* References are updated locally only, with `mantou test --update-snapshots`. CI never writes them. A failed comparison uploads the actual image, the expected one and a diff image as CI artifacts, as wgpu’s CI does.
* A bot command to accept changes can come later if updating by hand becomes tedious.
### 12.7 Benchmarks (tracked, not gating)
* **Commit:** creating 1,000 rows; updating one cell in 10,000 rows (time and ops).
* **Layout:** full and incremental layout of a 5,000-node tree.
* **Text:** measuring and laying out a 1 MB document; scrolling it.
* **Scroll:** frame time of a virtual list of 1,000,000 rows during a scripted fling.
* **Start-up:** process start → first frame presented (packaged app, cold and warm).
* **Memory:** idle footprint of the counter example; growth after 10,000 open/close cycles of a dialog, which should be none.
* **Input latency:** synthetic input → frame presented, measured by the core.
Regressions above a threshold open an issue automatically. They never fail PRs, because timings are noisy.
### 12.8 Testing decisions
* **T1 Clock — decided:** use `bun:test`’s fake timers for the JS side and drive the core’s clock with `mt_test_step`, both behind `app.clock` ([§12.1](/design/testing/#121-principles)). Verified in Bun 1.4.4: `jest.useFakeTimers()` moves `setTimeout`, `setInterval`, `Date.now()` and `performance.now()`, and `runAllTimers` exists. Bun has no `requestAnimationFrame`; Mantou provides its own on the core’s clock.
* **T2 Image comparison — decided:** pixelmatch-style comparison that ignores anti-aliasing, with `maxDiffPixels` defaulting to 0. References are updated locally only; CI uploads actual, expected and diff images ([§12.6](/design/testing/#126-ci-matrix)).
* **T3 macOS GPU in CI — decided:** use GitHub’s `macos-14` Apple Silicon runners. wgpu’s own CI (`gpu-test` job) runs its GPU and image-comparison tests on them, with WARP on Windows and Mesa on Linux, the same setup as ours. A dedicated Mac is needed only for benchmarks, because timings on virtual machines are noisy.
# Threading and architecture
> Why Bun's main thread is handed to the native event loop, and how the threads and components fit together.
## 4. The defining constraint: who owns the main thread
* **macOS:** AppKit must run on the main thread, inside a run loop that never returns (`[NSApp run]`).
* **Linux:** GTK and Wayland/X11 integrations expect the same.
* **Windows:** windows are tied to the thread that created them.
* **Bun:** the entry script runs on the main thread. Bun has **no public API** to run one tick of its event loop, and it doesn’t expose its epoll/kqueue file descriptor.
Calling the native loop from main-thread JS would freeze Bun’s timers, promises and I/O. Polling the native loop from a JS timer trades idle CPU against input latency.
**Decision:** the bootstrap starts a `Worker` running the app, then calls the core’s `mt_run()` on the main thread. That call blocks for the life of the process. All app JavaScript runs in the Worker.
**Validated (Linux, Bun 1.4.4):** with the main thread blocked inside an FFI call, the Worker’s timers kept their schedule and its async I/O completed. A native pthread also woke the Worker through `JSCallback({ threadsafe: true })`. The spike code is in [Appendix A](/design/decisions/#appendix-a--threading-spike-validated-on-linux-bun-144). **Spike results (2026-11-03), Linux:** passes with a real winit window and wgpu, under Xvfb with Mesa lavapipe; see [threading test results](/progress/threading/).
* winit’s event loop runs on Bun’s main thread inside `mt_run`.
* An idle window draws 0 frames and causes 0 wake-ups.
* `fetch` and timers in the Worker keep working through live resizes.
* With the Worker blocked in a busy loop for 1 s, the main thread kept drawing, and wake-ups coalesced to exactly one.
**macOS and Windows (2026-11-03):** the same self-test passes in CI ([run](https://github.com/dunkbing/mantou/actions/runs/41602279341)) on macOS (Apple Silicon, Metal) and Windows (D3D12, WARP). winit’s event loop runs on Bun’s main thread on all three desktops.
## 5. Architecture overview
```plaintext
┌──────────────────────────────── one process ────────────────────────────────┐
│ │
│ MAIN THREAD (native loop, no JS after bootstrap) │
│ winit event loop · windows · wgpu surfaces · IME · AccessKit · menus │
│ ├─ input ──► hit-test / scroll / focus in core ──► event queue ─► wake ──┼──┐
│ ├─ resize ──► lock tree, relayout (bounded wait) ──► repaint │ │
│ └─ RedrawRequested ──► composite latest scene (+ native scroll/anim) ──► │ │
│ wgpu present │ │
│ ▲ │ │
│ EventLoopProxy │ "scene ready" / window commands │ │
│ │ │ │
│ WORKER THREAD (Bun Worker: the app) │ │
│ TSX components ─► Solid signals ─► renderer ─► op encoder (ArrayBuffer) │ │
│ ▲ │ queueMicrotask │ │
│ │ ▼ │ │
│ dispatch handlers ◄─ mt_take_events() mt_commit(ptr,len) ─────────────┼──┤
│ ▲ │ (runs in core, on this │ │
│ └──────── onWake (threadsafe JSCallback) ◄┘ thread: apply ops, │ │
│ layout, text, scene) ◄─┼──┘
└─────────────────────────────────────────────────────────────────────────────┘
```
### 5.1 Threads and who owns what
| Thread | Runs | Owns |
| ---------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Main | winit loop; compositing and present; IME; AccessKit adapter; menus, dialogs, clipboard | Windows, wgpu device/queue/surfaces, GPU textures and atlases, scroll offsets, running animations |
| Worker (JS) | App code; Solid; op encoding. The core’s `mt_commit` runs here synchronously | JS mirror tree, signals, handlers |
| Core worker pool (optional, later) | Image decoding, font loading | — |
The **shadow tree** (nodes, styles, layout results, text layouts) sits behind a `Mutex` in the core. `mt_commit` (Worker thread) and resize relayout (main thread) both take that lock. The **scene** that drawing reads is an immutable snapshot (`Arc`) handed from commit to the main thread, so drawing never needs the lock.
### 5.2 Invariants
1. The main thread never calls into JS, and it never waits on the tree lock longer than one commit’s layout. During a resize it waits at most about 16 ms, then draws what it has.
2. JS never calls platform APIs directly. Everything goes through the core, which forwards to the main thread through winit’s `EventLoopProxy`.
3. Exactly one `mt_commit` per JS turn that changed anything. Zero FFI calls for turns that changed nothing.
4. Scrolling, compositor-only animations (transform, opacity) and hover effects driven by native state need no JS round trip.
5. A node is never re-inserted where it already is. A move is an `INSERT` with a new `before` anchor.
6. The Worker reports its own failures. Once `mt_run` starts, the main thread never reads Worker messages or `error` events, so an uncaught error or rejection in the Worker logs and calls `mt_quit(70)` itself. The bootstrap waits for the Worker’s `ready` message before it blocks. (Found by the threading spike.)
# Text and Mantou Mail
> Text rendering, rounded corners, borders, clipping, keys and the wheel — and Mantou Mail, a three-pane mail client built with them.

## Mantou Mail
[Mantou Mail](https://github.com/dunkbing/mantou-mail) is a mail client built only with what Mantou can do today, on sample data. It started in this repo and now lives in its own, where it uses Mantou the way any app does, as the `mantou-ui` package:
* **Sidebar:** all inboxes, each account, starred, snoozed, sent, archive and trash, plus labels, with unread counts.
* **Message list:** Important and Other tabs, and rows with an avatar, sender, time, subject, preview, unread dot, star and chips (“Waiting for reply”, “Snoozed until”, labels).
* **Reader:** the message, its labels, and quick replies (sending adds the reply to Sent).
* **Toolbar and status bar:** Archive, Reply, Older, Newer, Delete, and the last action with undo.
* **Keyboard:** `j`/`k` move, `e` archives, `#` deletes, `s` stars, `u` toggles unread, `r` opens quick replies (then `1`–`3` sends one), `z` undoes, `1`/`2` switch tabs.
* **Scrolling:** the wheel scrolls the list and the reader; the selected row is kept in view.
It runs in a window, where its first frame appears about 0.7 s after launch on Linux (mostly Babel compiling the app’s JSX), and on headless instances: its tests cover opening, keyboard triage and undo, tabs, the wheel, quick replies, the sidebar and captured pixels.
It was built before Mantou had [text input](/progress/text-input/), so it has no compose window or search yet.
## What the framework gained
| | |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Text** | `` with `color`, `fontSize`, `fontWeight`, `lineHeight`, `textAlign`, `fontStyle` and `maxLines` (with an ellipsis). Laid out in the core with parley, measured by Taffy, rasterized with swash into a glyph atlas ([§8.5](/design/protocols/#85-text)) |
| **Boxes** | `borderRadius`, `borderWidth`, `borderColor`: an SDF in the quad shader. Borders inset the content, as CSS’s `border-box` |
| **Clipping** | `overflow: "hidden"` clips descendants, intersected down the tree; hit-testing ignores what’s clipped away |
| **Input** | Keys with modifiers and named keys (`onKey`), the wheel (`onWheel`, bubbling like presses) |
| **Measuring** | `node.measure()`: where the core laid a node out, after committing the changes made so far |
| **Headless** | `HeadlessCore.key()` and `.wheel()`, and a `glyphs` counter |

## Tests
* **Text engine** (`mantou-text`): wrapping, the ellipsis, centred lines, weights, the layout cache, rasterization and atlas growth.
* **Tree** (`tests/text.rs`): text measured and wrapped, glyph quads, ellipsis inside the box, clipping, borders insetting content, a clipping box smaller than its content, text that keeps its logical layout across scale factors.
* **Incremental vs from scratch:** the random-change model now has text nodes, text changes and every text and box property. After each frame the incremental tree still equals one rebuilt from scratch, boxes to the bit.
* **Fuzzing:** the same three targets, with text in the `layout` target.
* **Headless and window:** the counter’s labels and count, keys, and Mantou Mail.
## Findings
* **Flexbox minimum sizes apply.** A flex item is at least as tall as its content unless it clips, which matches CSS. A scrolling list in a fixed-height window needs `minHeight: 0` on the flex containers around it, as in CSS. Mantou had to tell Taffy about `overflow: hidden` as well as clip, so a clipping box can be smaller than its content.
* **`flexShrink` defaults to 1**, as in CSS, so fixed-size parts (sidebars, avatars) need `flexShrink: 0`.
* **Fonts once per process.** Building a font context per tree (system font scan plus Inter) made the layout property test 35× slower. The context is now built once and cloned, and each tree creates its text engine only when its first text node appears.
* **Comparing scenes:** atlas positions depend on the order glyphs were first drawn, so trees are compared on everything else (a glyph’s size, place, colour and clip).
* **fontconfig keeps memory from its one-time setup**, which LeakSanitizer reports when fuzzing. `core/fuzz/lsan.supp` suppresses it.
# Text input
> : single-line and multiline text fields, edited in the core (caret, selection, undo, clipboard, IME) so typing never waits for JS.

## ``
```tsx
import { Input, createSignal } from "mantou-ui";
const [draft, setDraft] = createSignal("");
{ send(text); setDraft(""); }}
placeholder="Write a message"
style={{ height: 36, paddingLeft: 10, borderWidth: 1, borderColor: "#d1d5db", borderRadius: 8, fontSize: 15 }}
/>
```
| Prop | |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `value` + `onChange` | Controlled: the text follows the signal. If the app changes what the user typed (uppercasing, say), the input shows the app’s text; if the app doesn’t update the signal at all, the input goes back to it |
| `defaultValue` | Uncontrolled: the text it starts with; the core keeps it from then on, and `onChange` still reports every change |
| `onSubmit` | Enter in a single-line input, Ctrl+Enter (⌘+Enter on macOS) in a multiline one |
| `onFocus`, `onBlur` | The keyboard focus came or went (by a press, Tab, `node.focus()`, `node.blur()`, or a press elsewhere) |
| `placeholder` | Shown while it’s empty, in `placeholderColor` (default: the text colour at 40%) |
| `multiline` | Enter adds a line; the input grows with its text unless its height is set |
| `maxLength`, `readOnly` | Checked in the core, so no keystroke has to wait for JS to be refused. Read-only text can still be selected and copied |
| `autoFocus` | Focus it when it’s created |
| `style` | A ``’s box properties and a ``’s text properties, plus `caretColor` and `selectionColor` |
Without a width from its style or its parent, an input is 200 px wide (like a browser’s), and it has no minimum width, so it shrinks in a row. A single-line input is one line tall plus its padding, with its text centred vertically.
The `Node` behind it has `focus()`, `blur()`, `focused`, `value` (the text as the core last reported it), `setValue()`, `placeholder()` and `setOptions()`, for code that uses the node tree directly.
## Editing
The core does all of it, on the main thread for the window, without a round trip through JS:
* **Caret and selection:** arrows, Home and End, Shift to select; word by word with Alt (macOS) or Ctrl; line start and end with ⌘ on macOS; Up and Down between lines of a multiline input. Press to place the caret, drag to select, double-press for a word, triple-press for the line, Shift+press to extend.
* **Deleting:** Backspace and Delete, a word at a time with Alt (macOS) or Ctrl, to the line’s start with ⌘+Backspace on macOS.
* **Shortcuts:** select all, copy, cut, paste, undo and redo, with ⌘ on macOS and Ctrl elsewhere (also Ctrl+Y to redo). Typing is grouped into one undo step per word. The window uses the system clipboard (X11 on Linux, so XWayland on Wayland); each headless instance has its own.
* **IME:** compositions are drawn in place, underlined, and only become the value when the IME commits them. The IME’s candidate window is placed at the caret.
* **Focus:** Tab and Shift+Tab move through the inputs in tree order, selecting the new one’s text. A press outside any input blurs.
* **Scrolling:** a single-line input scrolls sideways to keep the caret in view, and so does a multiline input with a fixed height, vertically.
**Keys the focused input uses don’t reach `onKey`** (typing, arrows, Backspace, Enter, Tab, its shortcuts), so an app’s single-letter shortcuts don’t fire while the user types. Escape, Up and Down in a single-line input, PageUp and PageDown, and shortcuts the input doesn’t know (⌘+K, say) still reach the app.
## How it works
* `CREATE` kind `Input`, then `PLACEHOLDER`, `INPUT_OPTIONS` (flags and the maximum length) and `VALUE` ops; `FOCUS` gives or takes the focus ([§8.3](/design/protocols/#83-text-input)).
* When the user changes the text, the core sends `TEXT_CHANGED` with the text in the event record and a `rev` that counts the user’s edits. Event records now have a variable length: the first word’s high half says how many words follow the usual four.
* `VALUE` carries the `rev` the app last saw. The core ignores a value older than the user’s latest edit, so the app’s answer to “ab” can’t erase the “c” typed since; the app then hears about “abc” and answers again.
* **Op counts:** typing in an uncontrolled input sends nothing from JS. In a controlled one the echo (`onChange` setting the signal to the same text) sends nothing either; a value that differs is one `VALUE` op. Typing two letters in the window’s self-test draws them with no commit and no key events in JS.
## Not yet
Caret blinking; password fields (`secure`); character filters (`allow`) and `inputMode`; the selection in the API (reading it, or setting it from the app); focus for views other than inputs; scrolling a multiline input with the wheel; Emacs-style Control bindings on macOS; a context menu; accessibility.
## Tests
* **Editor** (`mantou-text`): typing, word movement and deletion, undo grouping and redo, newlines in single-line and multiline inputs, the maximum length and read-only, IME composition, the caret kept in view, the pointer, replacing the text from the app.
* **Tree** (`tests/input.rs`): focus by press and blur by pressing elsewhere, which keys are used, shortcuts and the clipboard, multiline growth and Ctrl+Enter, Tab order, stale and fresh `VALUE` ops, options, double-press and drag, what the scene draws (placeholder, caret, selection), the IME, releasing the focused input.
* **Headless** (`tests/input.test.tsx`): the same on the real core through ``, plus controlled inputs (echoes, transforms, a value the app holds fixed), submit-and-clear, keys reaching `onKey` or not, and a 128 KiB change, twice the event buffer, arriving whole. `HeadlessCore` gained `type`, `insertText`, `compose`, `clipboard`, `value`, `focused`, and `key(…, { primary: true })` for the platform’s shortcut key.
* **Mock core** (`runtime/input.test.tsx`): the ops an input sends, in order (options before the value, so a multiline value keeps its newlines).
* **Window:** the self-test types into an input and checks that the text reaches JS with no commit and no key events. The to-do example (`bun run todos`) was also driven with real X11 keystrokes and clicks under Xvfb: typing, Enter, Ctrl+A then Backspace, Tab into the notes, Shift+Home, double-press, and copying and pasting through the system clipboard.
# Threading model
> Test results for Mantou's threading model — Bun's main thread runs the native event loop while the app runs in a Worker — on Linux, macOS and Windows.
## What’s being tested
The design depends on one claim ([design §4](/design/threading/#4-the-defining-constraint-who-owns-the-main-thread)). Bun’s main thread can be handed to the native event loop for the life of the process, while all app JavaScript runs in a Worker, and Bun’s timers and I/O keep working. The window self-test (`spike/selftest.ts`, `bun run selftest`) checks that with a real window:
```plaintext
main thread bun runtime/main.ts → new Worker(app) → wait for "ready" → mt_run() (winit loop, wgpu, never returns to JS)
Worker app code → ops → mt_commit() core events → threadsafe JSCallback wake → mt_take_events()
```
| File | What it is |
| -------------------------- | ------------------------------------------------------------------------------------ |
| `runtime/main.ts` | Bootstrap on Bun’s main thread |
| `runtime/core.ts` | Worker-side client: one commit per JS turn, event decoding, crash reporting |
| `core/crates/mantou-ffi` | The C ABI, the winit event loop, the event queue with coalesced wake-ups, test hooks |
| `spike/selftest.ts` | The window self-test; writes `selftest-report.json` |
| `spike/app.ts` | Interactive demo: click to change colour, keys show in the title, Escape quits |
| `.github/workflows/ci.yml` | Runs the self-test on Linux, macOS and Windows |
## Results
**Linux x64, Bun 1.4.4, Xvfb, Mesa llvmpipe (software Vulkan), 4 vCPUs:** every check passes, in every run so far. A report from the first runs is [here](/reports/threading-linux.json).
| Check | Result |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Window + first frame on the main thread | ✅ about 110 ms from launch to first frame (warm), 720 ms cold |
| Commit from the Worker → frame | ✅ 1 commit → 1 frame, about 5 ms |
| Idle for 3 s | ✅ **0 frames, 0 wake-ups, 0 commits, 0.15% of one core** |
| Live resize (20×) while the Worker runs `fetch` in a loop and a 10 ms timer | ✅ 20 resizes → 20 frames → 20 events in JS; about 7,500 fetches, 0 failures; 119 of 120 timer ticks |
| Worker blocked for 1 s (busy loop) | ✅ main thread drew 12 frames meanwhile; **exactly 1 wake-up** (coalesced); all 12 events delivered afterwards |
**Real X11 input** (driven with xdotool’s XTEST): clicks change the colour, a key press shows in the title, and Escape quits with exit code 0.

**macOS and Windows:** the self-test passes in CI ([run](https://github.com/dunkbing/mantou/actions/runs/41602279341)) on GitHub’s `macos-14` (Apple Silicon, Metal) and `windows-2022` (D3D12, WARP) runners. Each run uploads its `selftest-report-*` artifacts.
## Findings
* **The main thread must never be the only one that knows about an error.** Once `mt_run` starts, the main thread runs no JavaScript, so it never reads Worker messages or `error` events. The Worker reports its own uncaught errors and quits (`runtime/core.ts`). The bootstrap waits for a `ready` message before blocking.
* **Linux runtime dependencies:** winit loads `libxkbcommon-x11` at run time, and a missing library panics at start-up. The panic is caught at the C boundary and reported as exit code 101 with a clear message, but packages (`.deb`, AppImage) must declare these libraries.
* **Synthetic X events are ignored:** winit ignores `XSendEvent` key events, so test input must use XTEST or the core’s own input hook ([§12.2](/design/testing/#122-headless-mode-core-support)).
* **wgpu 30** takes the display handle at instance creation, and OpenGL (EGL) needs it. The self-test also passes on the GL backend (`WGPU_BACKEND=gl`).
* **Memory:** about 150 MB RSS in the self-test, almost all of it Mesa’s software rasterizer, which carries an LLVM JIT. Bun plus an idle Worker measured 24 MB.
* **Size:** the core library is 7.9 MB stripped on Linux; the unstripped build with line tables is 59 MB.
# UI tree, layout and rendering
> Test results for the op protocol, the shadow tree, flexbox layout, the rectangle renderer, presses and headless instances.
The Worker builds a UI tree and sends it to the native core as **ops**; the core lays it out and draws it. This page records what works and how it’s tested.

## What works
**The protocol has one source of truth.** [`schema/protocol.ts`](https://github.com/dunkbing/mantou/blob/main/schema/protocol.ts) defines every op, node kind, style property and event. `bun run gen` writes the TypeScript bindings (`runtime/gen/protocol.ts`), the Rust bindings (`core/crates/mantou-tree/src/protocol.rs`) and a golden op batch (`schema/fixtures/basic.*`). The TypeScript encoder writes that batch; the Rust decoder must decode it to exactly what the TypeScript decoder does. `bun run gen:check` fails CI when anything is stale.
**The shadow tree** (`mantou-tree`, no platform dependencies):
* **Applying batches:** whole batches are validated first, and a malformed one changes nothing. Ops that make no sense (unknown ids, cycles) are skipped and counted.
* **Moves:** a node already in place is never re-inserted.
* **Styles:** shared styles (`STYLE_DEF`/`STYLE_USE`) with per-node overrides.
* **Layout:** flexbox through Taffy, rounded to device pixels by Mantou rather than Taffy.
* **Output:** a scene of rectangles, and hit-testing for presses.
**The renderer** (`mantou-render`): solid rectangles drawn as instanced quads with wgpu, colours converted to linear for sRGB targets. It draws to a window’s surface or offscreen.
**The native core** (`mantou-ffi`, C ABI version 3): every UI is an **instance** with its own tree, scene, events and counters; the window is one (`mt_app`).
* **Window:** commits apply, lay out and publish a new scene on the Worker’s thread. The main thread draws the latest scene and lays out again on resize without involving JS. A press (down and up on the same node) whose path has a listener is sent as a `PRESS` event.
* **Headless instances** (`mt_headless_new`, [§12.2](/design/testing/#122-headless-mode-core-support)) run without a window, synchronously on the caller’s thread, with a manual clock: input (`mt_test_input`), viewport (`mt_test_viewport`), stepping (`mt_test_step`), hit-testing, node boxes, a text dump of the scene (`mt_test_scene`) and offscreen captures (`mt_test_capture`).
**The runtime:**
* **Nodes** (`runtime/ui.ts`): a mirror tree of `Node`s with `style`, `use`, `append`, `insertBefore`, `remove`, `release` and `onPress`. Presses bubble, and `stopPropagation` stops them.
* **Batching:** one commit per JS turn.
* **Ids:** released ids are reused.
* **Tests:** `runtime/mock.ts` records decoded commits; `runtime/headless.ts` (`HeadlessCore`) drives a real headless instance.
**The counter example** (`examples/counter`, `bun run counter`): a Solid component, `Counter.tsx`, written with `View`, `createSignal`, `Index` and shared styles. When this page was written there was no text, so the count showed as squares; it now has labels and the count as text ([Text and Mantou Mail](/progress/text/)).
**The Solid layer** ([§7.2](/design/runtime/#72-components-reactivity-compiled-jsx)):
* **Compilation:** `.tsx` files are compiled by a Bun plugin (`runtime/solid/plugin.ts`) with `babel-preset-solid` in universal mode; compiled JSX calls the renderer in `runtime/solid/renderer.ts`.
* **Rendering:** Solid’s universal renderer over the mirror tree; `` takes `style` (own properties, a shared style, or a list of them), `onPress` and `ref`. Nodes are released when the Solid owner that created them is disposed.
* **Entry points:** `run(app)` renders in the window from the Worker; `mount(ui, app)` renders into any `Ui`, which is how tests use the mock core and headless instances.
## Tests
| Suite | Command | What it checks |
| ---------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tree unit tests | `bun run test:core` | Layout, moves, release, cycles, malformed batches, device-pixel snapping, presses, shared styles |
| Cross-language fixture | `bun run test:core` | Rust decodes the TypeScript-encoded batch exactly as the TypeScript decoder does |
| Incremental vs from-scratch layout | `bun run test:core` | 400 random UIs × 40 frames of random valid changes, plus 10 runs of 400 frames: after each frame, every node’s box and the whole scene equal those of a tree rebuilt from scratch, **to the bit** |
| Random and corrupted batches | `bun run test:core` | 20,000 random batches and 3,000 corrupted valid ones never panic, and the tree stays consistent (`Tree::check`) |
| Fuzzing | `cargo +nightly fuzz run ` in `core/fuzz` | `decode` (any bytes), `apply` (any batches, then layout, scene, hit-testing, consistency), `layout` (fuzzer-driven valid changes, incremental vs from scratch) |
| Runtime | `bun run test` | Encoder/decoder round trip, colours, validation, op counts, moves, bubbling, listening; the Solid counter against the mock core: one batch for the first render, **4 ops** per square, one commit for several presses in a turn, everything released on dispose |
| Headless core | `bun run test:headless` | The Solid counter on the real core without a window: layout, presses (a press commit is exactly **4 ops**), wrapping, releasing, resizing at 2×, `` adding and removing nodes, captured pixels, independent instances |
| Window | `bun run selftest` | The threading checks, plus, with the Solid counter: ops → layout → quads; a press → handler → one commit → frame (about 7–13 ms); no press without a listener; relayout while the Worker is blocked (8 resizes → 8 layouts, 0 commits) |
**Fuzzing so far** (60 s per target, Linux): `decode` 5.5 million runs, `apply` 657,000, `layout` 98,000; no failures. The random batch test found one bug before that: a `STYLE_DEF` with a garbage property count reserved memory for it before checking it, and a count of 2³² aborted the process. The count is now checked against the op’s length first, with a regression test.
**Layout gap found by the headless tests:** with `flexWrap`, CSS’s default `align-content: stretch` spreads wrapped rows over the container’s height. The protocol had no `alignContent`, so rows couldn’t be packed at the top; it does now.