oriel

Architecture

How Oriel is layered so one app, its modules and its Zig code run under three renderers that share everything below their front end.

An Oriel window can be drawn three ways: by the platform’s web view, by Oriel’s native renderer running the same HTML, CSS and JavaScript without a browser, or by the experimental Oriel renderer, which runs views written in .oriel markup from Zig with no JavaScript at all. They are front ends over one stack: each renderer turns its own language into native views, and everything under that (the engine machinery, the input rules, layout, drawing, the platform’s widgets) and everything beside it (commands, modules, permissions, the platform layer) is written once and shared.

This page is the map: which layer owns what, what each renderer adds, and where new behavior goes. Concepts covers the app’s side (the process, the bridge, commands and events).

The layers

flowchart TB
  subgraph App["The app (Zig)"]
    Cmd["Commands, events,<br/>app state"]
    Mods["Modules: tray, files, sql,<br/>local AI, camera, ..."]
  end
  subgraph Front["Front ends: one per renderer"]
    WV["WebView<br/>the platform's browser engine"]
    NW["Native renderer<br/>QuickJS, native DOM, CSS,<br/>main.js, render.js"]
    OR["Oriel renderer (experimental)<br/>.oriel views, ui.zig, focus.zig"]
  end
  subgraph Shared["Shared native UI (src/native_ui)"]
    Core["Engine core<br/>engine_core.zig"]
    Rules["Input rules<br/>interaction.zig"]
    Tree["Node tree and layout<br/>tree.zig, Yoga, text"]
    BE["Backends<br/>gtk, win32, appkit, uikit, android"]
  end
  Plat["Platform layer<br/>src/platform: windows, event loop, bridge"]
  Cmd <-->|"invoke, events<br/>(window.oriel)"| WV
  Cmd <-->|"invoke, events<br/>(window.oriel)"| NW
  Cmd <-->|"ui.invoke, Driver<br/>(direct Zig calls)"| OR
  NW --> Core
  OR --> Core
  NW -->|"host.tabNext, focusRing, ..."| Rules
  OR --> Rules
  Core --> Tree --> BE
  Mods --> Plat
  WV --> Plat
  BE --> Plat

Each layer only calls the layers under it. A front end never draws and never reads a platform API; a backend never knows which front end built the nodes it draws. That is what lets a fix or a feature in a lower layer reach every renderer at once.

LayerSourceOwns
Appthe app’s ZigCommands, events, state, which modules it turns on
Modulessrc/modules/*Tray, notifications, files, SQL, local AI, …: the same API under every renderer (Modules)
Front endWebView: the platform’s; native: src/native_ui/js/src, engine_js.zig, dom/; Oriel: oriel_markup.zig, ui.zig, engine_zig.zig, focus.zigIts language: parsing it, styling, deciding which native nodes exist and what they say, delivering events to the app’s code
Engine coresrc/native_ui/engine_core.zigThe open engines, frame callbacks, painting at most once a frame, scrolling, the key scroll’s glide
Input rulessrc/native_ui/interaction.zigHow the platform’s WebView answers the keyboard and pointer: focus rings by kind of element, Tab’s order, what a click focuses, which keys activate, how far a key scrolls
Node treesrc/native_ui/tree.zig, text_measure_cache.zig, svg_path.zig, zig_canvas.zigOne node per drawn view: Yoga layout, paint order, scrolling, hit testing, accessibility entries, tooltips
Backendssrc/native_ui/gtk.zig, win32.zig, appkit.zig, uikit.zig with apple_draw.zig, android.zig with OrielNative.ktDrawing, text measurement, the platform’s widgets (fields, buttons, checkboxes), input events, the screen reader
Platform layersrc/platform/<os>Windows, the event loop, the web view and its bridge, the command policy (Concepts)

The three renderers

WebViewNative rendererOriel renderer
Builddefault-Dnative_ui-Dzig_ui (experimental)
You writeHTML, CSS, JavaScriptthe same files, unchanged.oriel views and Zig
Runs the UIthe platform’s browser engine, in its own processesQuickJS-ng and Oriel’s native DOM, in the app’s processcompiled views and the app’s Zig, in the app’s process
Draws withthe browser enginethe shared tree and backendsthe shared tree and backends
Talks to Zigwindow.oriel: messages to the appwindow.oriel: direct calls in the processZig calls: ui.invoke, the Driver callbacks
Statusstable, every platformexperimental: Linux, Windows, macOS, Android, iOSexperimental, not yet for apps: Linux; builds for Android

The web view shares the app, the modules and the platform layer; the two native renderers also share the whole native UI stack. In the native renderer the front end is JavaScript because the page is; in the Oriel renderer there is none, so a view’s update is a few Zig calls instead of a DOM mutation, a restyle and a flatten.

Measured on the system monitor (2026-10-13, Linux, three runs each, software rendering on a private X display, the same 2 s sampling): the Oriel renderer opens its window in 0.18 s against 0.25 s, uses 0.70% CPU idle against 0.97% and 5.8% while scrolling the process list against 11.3%. Memory is about the same (246 MB RSS each): most of it is the toolkit and graphics stack both load, and leaving out QuickJS and the DOM saves 6 to 8 MB of heap.

What is shared, and how

One tree, any front end. Both native front ends build the same tree.zig nodes with the same props: the native renderer from the styled DOM (render.js), the Oriel renderer from compiled .oriel templates (ui.zig). Layout, text, scrolling, hit testing and drawing don’t know which. A prop added to the tree and drawn by a backend is available to both: tooltips (tip) are one prop and one backend change.

One engine core. Each window has one engine, engine_js.zig or engine_zig.zig, and both are thin: their front end’s lifecycle and events. The rest is engine_core.zig: the engines by serial (a Zig handle that outlived its window finds nothing), the app’s display frame callbacks (a callback may cancel itself or add another during a frame), changes painted at most once per display frame however many events came, scroll offsets, and the glide that animates a key scroll on display frames.

One set of input rules. How a window answers the keyboard is measured from each platform’s WebView and kept in interaction.zig: the focus ring WebKitGTK, WebView2, Android’s WebView and WKWebView draw around each kind of element; which elements Tab visits on macOS without Full Keyboard Access and on iOS; a radio group being one Tab stop; WebKit not focusing a button on a click; Enter and Space activating; the 40 px and 87.5% a key scrolls and the curves it glides on. The Oriel renderer applies them to its tree. The native renderer’s page keeps what only a page can do (dispatching keydown, honouring preventDefault, finding candidates in document order) and asks the engine for the decisions (host.tabNext, host.clickFocuses, host.activates, host.keyScroll, host.focusRing). The runtime’s Node tests get the same rules compiled to WebAssembly (src/native_ui/interaction_wasm.zig, zig build interaction-wasm).

One accessibility tree. Both front ends describe their elements as entries in Tree.ax (role, name, states): the native renderer from the DOM’s roles and ARIA (a11y.js), the Oriel renderer from its elements and role/label attributes (Tree.setAxEntry). The backends hand them to the screen reader.

Modules don’t know the renderer. A module is Zig on the app’s side of the bridge. Under the web view and the native renderer the page reaches it through the same window.oriel (src/core/bridge_api.js), checked by the same command policy (src/core/native_commands.zig); under the Oriel renderer the app’s Zig calls it directly. Turning a module on is the same build flag in all three (Modules).

Where new behavior goes

Put a behavior in the lowest layer that can own it, so every renderer gets it:

It is aboutIt goes inExample
How something is drawn or what widget shows itthe backends, through a tree propa tooltip, a native checkbox
Where things are, what’s under the pointer, scrollingtree.zigsticky positioning, hit testing
How the platform’s browser answers inputinteraction.zigTab’s order on macOS, the focus ring’s offset
When things run and get paintedengine_core.zigframe callbacks, one paint per frame
A language: HTML/CSS/DOM, or .orielthat front endCSS’s cascade, an .oriel element
Something the app or the page callsa module or a commandthe tray, SQL

A rule that both front ends need is written once, in Zig, and the front end that can’t call Zig directly (the page’s JavaScript) asks through a host function. A front end keeps only what belongs to its language.

Background

Oriel started from two apps’ needs. GhostPen, AI text editing anywhere on the desktop, needed a tray, global shortcuts, typing into other apps, the clipboard, a single instance and network calls. GhostReel, local video search and AI-assisted editing, needed media streaming, dialogs, SQLite with vector search, whisper.cpp, llama.cpp and packaging. Both started in Rust; its build sizes and iteration time were the reason to start over in Zig 0.16, which gives small binaries, direct C interop, comptime reflection and one build system for desktop and mobile targets.

Limits

  • The native renderer doesn’t promise full browser compatibility. CSS, DOM, accessibility and JavaScript behavior it doesn’t support yet belongs on the web view until it’s implemented and tested.
  • Native renderers keep the UI thread’s affinity: commands and model work run on workers, and their results go back to the platform’s main loop.
  • Linux spans GTK, Wayland, X11 and several portal implementations; macOS, Windows and the phones each need their own SDK and testing on the real platform.
  • Zig 0.16 and several packages around it still move: dependency versions stay pinned and tested in CI.
  • Security is part of the framework’s contract: capabilities, asset origins, navigation and IPC validation change together with each new module.

Related

Search documentation

Type to search the documentation.