native-rendering
Native rendering — the engine emits scenes, the viewer draws itself
A seed + roadmap document, written for the next agent (human or model). It captures *why* zero stopped being an HTML-over-webview app and became a thing that hands every viewer a scene and lets it draw with real native widgets — and *how* that ships without breaking a single page that exists today. Status: built and proven. The engine substrate (functions/ui/scene.cpp), the reference HTML renderer, the component class library (ui-kit/), and the macOS native renderer (desktop/scene_mac.h) are done and verified end to end. The Android / iOS / Windows / Linux renderers live in their shells and build in CI. See *Status* below for exactly what is verified where.
The one idea
For its whole life zero rendered exactly one way: an action emit-ed HTML bytes and a webview painted them. That couples every screen to HTML + WebKit. But zero is meant to *become the device*, and a real native button / navbar / list is more native than any webview can be.
So we decoupled what is on screen from how it is drawn:
• An action no longer says *how a thing looks*. It declares what is on screen
as a tree of semantic components — screen, navbar, tabbar, row, column, card, list, item, heading, text, button, field, image, form, divider, spacer. That tree is a scene.
• The engine hands the scene to whoever is looking and lets the *viewer* draw
itself:
• a native shell (Android, iOS, macOS, Windows, Linux, TV) asks for the
scene wire format and renders real native widgets;
• a browser / webview gets the SAME scene rendered to a self-contained HTML
document by the engine's reference renderer — so nothing that ships today breaks.
*"Website will get this, Android will get that, whoever the viewer is — draw yourself."* The engine is the invisible floor; the viewer is the framework. We are the framework.
Why this is in the grain (not new machinery)
Three facts that already shipped made this small, not large:
1. A view is already a computed projection, assembled from components. The
view class composes a page from named component actions (_v_hero, _v_menu, _v_actions, …). We did not invent "components" — we gave the component layer a viewer-agnostic output instead of hard-coded HTML.
2. The viewer already reaches the DSL. __door (desktop / mobile / tv / web /
cli) told the engine *who is looking*; we added __accept (the request's Accept header) so the same action can answer two ways from one body.
3. Design tokens already live on thing. accent / ink / bg / navbg /
navink / radius / font are inherited by every object. A scene carries those same tokens, so native widgets and HTML share one source of design truth.
The substrate (engine, C++)
functions/ui/scene.cpp is one translation unit (the arena + open-container stack are per-request thread_local state every verb shares). It registers five primitives and one host hook:
DSL verb
effect
functions/ui_screen [k v]…
start a fresh scene; root is a screen node
functions/ui_begin <type> [k v]…
open a CONTAINER node, make it current
functions/ui_node <type> [k v]…
add a LEAF node to the current container
functions/ui_end
close the current container (back to its parent)
functions/ui_render
finalize: native viewer → scene JSON, web viewer → HTML; reset the arena
ui_reset() (host)
called by dispatch() per request so a pooled worker never leaks a half-built scene
Props are alternating key value tokens (each already resolved by the interpreter, so a value is a "literal" or a member's value). Unknown props pass straight through to JSON, so a new native capability never needs an engine change to be expressed.
Negotiation (ui_render): native is OPT-IN, never forced. A request is drawn as a scene when Accept contains application/vnd.zero.scene+json or the query carries view=native; otherwise it is HTML. So adding native rendering can never regress a webview/browser screen.
Wire format (application/vnd.zero.scene+json):
{ "zero_scene": 1, "door": "mobile",
"theme": { "accent": "#0a84ff", "ink": "#1a1a1a", "navbg": "#111", … },
"root": { "type": "screen", "kids": [ {"type":"navbar","props":{"title":"Orders"}}, … ] } }
Every node is { "type": string, "props"?: {string:string}, "kids"?: [node] }.
The components are an OOP class hierarchy (store)
The store side is plain zero OOP — folders + parent = inheritance:
thing (root: theme tokens, access, drives)
└── component (ui-kit/component) — a thing whose job is to be DRAWN
└── screen (ui-kit/screen) — a full surface; inherits the chrome
└── native (ui-kit/native) — a demo screen authored as components
screen carries the inherited chrome — _topbar (a navbar from the screen's title) and _tabbar (the bottom tabs). A subclass writes an index action that calls ui_screen, composes components, and ui_render; it gets the chrome for free and overrides _topbar / _tabbar to change it. The node *types* are the canonical taxonomy the engine + every renderer agree on; the store classes are the ergonomic, inheritable building blocks composed from them.
The ui-kit/ folder in this repo is the reference copy of these classes. The canonical home is the store; promote with store.push <class>. They are loaded from ~/zero.9.stores at runtime like any class.
The viewers (renderers)
Viewer
Where
Draws with
Web / any WebView
functions/ui/scene.cpp (server-side)
a self-contained HTML document (theme tokens → CSS variables)
macOS
desktop/scene_mac.h
AppKit (NSStackView, NSButton, NSTextField, …)
Linux
desktop/main_gtk.c
GTK3 (GtkBox, GtkButton, GtkEntry, …)
Windows
desktop/main_win.cpp
Win32 controls (BUTTON, EDIT, STATIC)
Android / Android-TV / Fire-TV
mobile/android/.../SceneRenderer.kt
android.widget views
iOS / iPadOS
mobile/ios/Sources/SceneRenderer.swift
UIKit
A shell renders natively when its manifest says render = native; otherwise it keeps the WebView door, unchanged. desktop/native.manifest + make desktop APP=native builds the macOS native demo (ZeroNative.app, serving native/index).
The interaction contract every renderer honors: a button/item/card with an action navigates (GET, Accept: …scene+json); a button with method=post (or a submit button inside a form) collects the on-screen fields, url-encodes them, and POSTs; navbar.back == "1" pops a small back stack. Same semantics, different draw — that is the whole point.
Explicit website open (the native-first rule): a native shell renders ONLY scenes inline — there is NO HTML fallback. A node opens a WEBSITE only when it asks: its action is a full http(s):// URL, or it carries external = "1". The renderer then hands that URL to the platform's website opener (Android: a dismissible in-app WebView overlay; macOS/Linux/Windows: the system browser; iOS: SFSafariViewController) instead of scene-fetching it. A relative action without external is always a scene navigation. So "the website is barred unless we call it" — from a menu, navbar, or drawer. A non-scene response to a native viewer is an error surface, never a webview.
Status
• [x] Engine substrate functions/ui/scene.cpp (5 primitives + ui_reset),
__accept threaded through every driver (http / cgi / cli / stream).
• [x] Reference HTML renderer (server-side, self-contained doc, theme tokens).
• [x] Component class library ui-kit/ (component → screen → native) + a
demo screen exercising every node type.
• [x] Verified end to end on the engine: one native.index action serves
HTML to a browser and a scene to a native viewer; a form POST round-trips in scene form (verify-scene.sh).
• [x] macOS AppKit renderer (desktop/scene_mac.h) — compiles, bundles with
render = native.
• [x] Android / iOS / Windows / Linux renderers in their shells — built in CI.
• [x] The default face is a SCENE (thing.content = _scene_face,
scene_view = 1 on the root): every thing renders its structure (top bar, hero, its data as a list, its actions as buttons) as a scene — native widgets on a shell, HTML derived by ui_render in a browser. There is NO HTML-specific default view; a class never emits HTML as its face.
• [x] chat is native (thing.chat): a conversation (message cards + a composer)
built as a scene; the browser renders the same scene. home, node, console, projects, the twin (person) and every plain thing are native scenes.
• [x] web_only = the one exception: a deliberate WEBSITE (marketing app/
landing/site, the WebAuthn auth pages, visual/admin tools like world/gl/ control/infer, the apis/openapi catalogs). HTML on the web; on a native shell reached only by an EXPLICIT external menu item (the platform web door). No HTML fallback ever; a non-scene to a native viewer is an error surface.
• [x] Contextual menus on every scene: a slide-out _drawer (Home + the thing's
role-aware nav + owner tools) AND a bottom-nav _tabbar (Home / Chat / Node), both per the viewer's lens. The drawer also populates the desktop OS menu bar (a native "Go" menu) on macOS / GTK / Windows.
• [ ] Apple TV (tvOS, no WebView): the native renderer is the *only* path there —
this protocol is exactly what unblocks it.
• [ ] Richer components (tabs, sheets, pull-to-refresh, lists with sections) as
new node types — additive, no engine change for props.
Where to go next
• functions/ui/scene.cpp — the substrate + the HTML reference renderer.
• desktop/scene_mac.h — the canonical native renderer to mirror.
• local://scene-protocol.md (build-time) / this doc — the wire contract.
• ui-kit/ — the component class hierarchy + the demo screen.
• [VISION.md](../VISION.md), [lenses.md](./lenses.md) — the same spine: thin
engine, meaning in the store, the viewer wears its own face.
The view-layer split (the architecture, not a per-action hack)
The load-bearing rule: an action never decides HOW it is shown. A thing's content declares WHAT is on screen *once* — semantic components, built with functions/ui_*. The view layer (thing.render → ui_render) is the only place that knows the viewer, and presents that one scene per how it is seen: native widgets to a shell, a self-contained HTML document to a browser/webview, canvas data to a canvas. The same action result, many faces.
• A class's content builds a SCENE; the DEFAULT (thing.content) is the universal
_scene_face (the thing's structure), so a thing is native with no per-class work. No if native … else html branch ever lives in an action — and there is no bespoke HTML "view": the structure is the only definition, parsed by each viewer.
• thing.render reads the posture (both inherited): a SCENE (scene_view = 1,
web_only empty — the default) → _render_scene (content builds it, _drawer + _tabbar wrap it, ui_render presents); a WEBSITE (web_only = 1) → _render_web_only (a browser gets the HTML page; a native viewer a tiny "open website" scene).
• Scene by default: home, node, console, chat, projects, the view FACE and
every plain thing (person, clinic, portfolio, model, …). Websites (web_only): the website/site/landing chain + app marketing, the auth pages (login/register/identity/trust, WebAuthn — the platform web door), visual/ admin tools (world, gl, control, infer, network, skill/deck), and the apis/openapi catalogs — each reached only by an explicit external item.
• Making a thing a website is the rare opt-in (web_only = 1); making one native is
the default — nothing to do.