Warren (Electrobun UI)
Warren is a small, reactive UI framework that renders through Dawn
(WebGPU) directly from the main process. There is no webview, no browser
DOM, and no build tooling: you write .tsx against electrobun/main/ui,
and Cottontail’s built-in transpiler — the same one that already handles
your TypeScript — does the rest. Nothing gets added to your toolchain.
signals / stores (signal, store with mutator setters) ↓ explicit live() scopes, one binding per expressionretained UI tree ↓layout + hit testing ↓one instanced draw call → Dawn surfaceRendering is invalidation-driven: layout, paint, and the GPU draw only run when something changed. An idle window costs almost nothing, and a hidden window costs literally nothing.
Hello world
Point the Cottontail entrypoint at a .tsx file:
import type { ElectrobunConfig } from "electrobun";
export default { app: { name: "hello-ui", identifier: "hello-ui.example.com", version: "0.0.1" }, build: { mainProcess: "cottontail", cottontail: { entrypoint: "src/main.tsx" }, mac: { bundleCEF: false, bundleWGPU: true }, linux: { bundleCEF: false, bundleWGPU: true }, win: { bundleCEF: false, bundleWGPU: true }, },} satisfies ElectrobunConfig;Tell TypeScript (your editor and tsc) which JSX types to use. This is
type-checking configuration, not a build step — Cottontail transpiles .tsx
natively either way:
// tsconfig.json (for editor/typechecking only){ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "electrobun/main/ui" } }Then write components:
import { live, signal, createUIWindow } from "electrobun/main/ui";
function Counter() { const [count, setCount] = signal(0); return ( <column grow={1} justify="center" align="center" gap={16}> <text size={32} color="#e4e4f0">{live(() => `Count: ${count()}`)}</text> <box pad={12} radius={8} bg="#1b1b28" border={1} borderColor="#3b3b58" onClick={() => setCount((c) => c + 1)} > <text size={14} color="#e4e4f0">Click me</text> </box> </column> );}
await createUIWindow({ title: "Hello UI", width: 420, height: 240 }, () => <Counter />);Run it with hutch electrobun dev. Three rules cover everything:
- Reactivity is explicit.
live(() => ...)creates a reactive scope — one binding updating exactly one thing when its dependencies change. Static values stay bare (bg="#1b1b28"), and bare functions in value positions are an error. Search a file forlive(to find every reactive boundary; see Warren reactivity. - Components are plain functions called once with their props — no re-rendering, no hooks rules, no registration.
- Handlers are plain functions (
onClick={() => ...}) — never tracked.
What the JSX becomes
There is exactly one transform, and Cottontail’s transpiler applies it
automatically: each JSX tag becomes a jsx() call that returns a lazy
element, and mounting runs those elements against the retained tree. This:
<column gap={16}> <text size={32}>{live(() => `Count: ${count()}`)}</text></column>is transpiled to (and behaves identically to):
jsx("column", { gap: 16, children: jsx("text", { size: 32, children: live(() => `Count: ${count()}`) }),});which, when mounted, drives the underlying builder API —
ui.column(...) / ui.text(...) — the same functions you can call directly
from plain .ts if you prefer no JSX at all. The two styles are one API.
Embedding a webview
The UI tree can position real out-of-process webviews. A bare function child
is a builder escape — it runs builder-API code (here the webview
element) at that spot in the tree:
import { webview, createUIWindow } from "electrobun/main/ui";
await createUIWindow({ title: "Hybrid", width: 900, height: 600 }, () => ( <row grow={1}> <column width={240} pad={16} gap={8} bg="#16161e"> <text size={13} color="#e4e4f0">GPU-rendered sidebar</text> </column> {() => webview({ grow: 1, url: "https://electrobun.dev" })} </row>));Resize the window and both panes reflow together: the sidebar is drawn by
the UI runtime, the page is a real webview, and one layout tree owns both
rectangles. webview also accepts html, partition, and sandbox like
BrowserView.
Embedding a WGPU layer
wgpuSurface gives you a raw Dawn view positioned by the layout — your own
render pipeline inside a UI-managed rectangle:
import { webgpu } from "electrobun/main";import { wgpuSurface, createUIWindow } from "electrobun/main/ui";
await createUIWindow({ title: "GPU panel", width: 720, height: 480 }, () => ( <column grow={1} pad={12} gap={8}> <text size={12} color="#8c8ca8">Custom WGPU below</text> {() => wgpuSurface({ grow: 1, onReady: async (view) => { const { context } = webgpu.createContext(view); const adapter = await webgpu.navigator.requestAdapter({ compatibleSurface: context, }); const device = await adapter.requestDevice(); context.configure({ device, format: "bgra8unorm" }); // ...create your pipeline, render into context.getCurrentTexture()... }, }) } </column>));The view handed to onReady is a regular
WGPUView — everything in the WebGPU adapter
API works against it. The ui-wgpu template renders an animated Mandelbrot
this way.
Where to go next
- UI overview — windows, mounting, and how rendering works.
- Components — every element and its props.
- Warren reactivity — signal, store, live, memo.
- Warren in the browser — the DOM
renderer (
electrobun/browser/ui): same reactivity and JSX, rendered into real DOM inside a webview. <electrobun-ui>tag — layering reactive native UI on top of web content.- Templates:
ui-wgpu(JSX counter + embedded webview + Mandelbrot),ui-color-picker(tray eyedropper),ui-launcher(command palette).
Current limitations
- On macOS, text renders with system fonts (CoreText) and input is native events (pointer, wheel scrolling, keyboard-layout characters). Other platforms currently fall back to a built-in 5x7 bitmap font and polled input with a US-layout keymap.
- No text wrapping or truncation yet: long strings widen layout instead of clipping. Size strings to their containers.
- Layout is a flex subset: row/column, gap, padding, fixed/auto sizes, grow, justify, align, and scroll containers (wheel-scrollable on macOS).
- Electrobun UI runs in the privileged main process — use it for trusted application UI, not untrusted content (that’s what webviews are for).