Decisions
Generated from DECISIONS.md when the site was built at 7aa23e2.
The DOM as a native platform layer: a real DOM (CSS, selectors, layout) whose output is each platform’s native UI, programmable from any language. JS frameworks running unmodified is a consequence, not the point. Neither is dependencies just working: a library like dnd-kit, Floating UI or Tiptap should run because the DOM APIs it uses behave as they do in a browser.
- The DOM is blitz-dom, used as a normal dependency (no fork), wrapped in our own crate.
- Every language reaches it through one public API over node ids: create, mutate, query, style, listen. The API is each crate’s public Rust API, which is its contract, generated from WebIDL. A C ABI is generated from the same IDL as one target among several, for C, Zig, Go and anything without better interop.
- The core owns style, layout, and event dispatch. Listeners are callbacks registered through the C API from any language.
- The DOM runs on the main thread, next to the native toolkit. Synchronous platform queries (text input geometry, accessibility, hit testing, native control measurement) are direct calls. Output is still one mutation batch per frame.
- The core stays
Sendso it can move to its own thread later. Heavy work goes to Web Workers; dev builds warn on long tasks. - Handles are generational (index + generation); a stale handle is an error, never a different node.
- Lifetime follows the DOM spec: the document owns the tree; detached nodes live while a handle holds them. The core offers strong and weak handles and notifies bindings when a node becomes unreachable from the document. Cycles through callbacks and wrapper identity are each binding’s concern, solved with its language’s own idiom.
- Calls are synchronous and DOM-thread-only; layout reads flush style and layout. Batching exists only on the output side.
- Strings are UTF-8 plus length; errors are DOMException codes; listeners are function pointer + userdata + destructor.
- The API surface comes from the DOM’s WebIDL: the C API and each language binding are generated from it as a build step.
- The mutation stream is a real wire format, not in-memory structs.
- A conformance suite runs against the C API, not against blitz-dom, so alternative DOMs stay honest.
- Languages bind niso through the safest good interop they have, not necessarily C: Rust directly, Swift and Kotlin through generated bindings (UniFFI-style) or the C ABI, Java through JNI from Rust, Python through PyO3, Node/Deno/Bun through Node-API from Rust, C/Zig/Go through the C ABI. JS engines are bound from Rust through their safe embedding crates where they exist (rquickjs for QuickJS, the v8 crate for V8); engines that only expose C (Porffor’s generated code) get a thin unsafe adapter. Every binding uses crates’ public APIs, never a crate’s internals, and every entry point keeps the same call discipline (panic boundary, borrow checks, safe point, turn bookkeeping) whichever language it comes from. A Wasm host that implements
web-sys-style imports natively (Leptos, Kotlin/Wasm, Go, Blazor) comes later, on top of the C API.
JS engines
Section titled “JS engines”- Bindings target one small engine interface: host functions, weak refs, finalizers, microtasks.
- QuickJS for the dev loop. Porffor for shipping (AOT; iOS forbids JIT). Static Hermes evaluated against Porffor on the same tests.
- Every engine passes a conformance smoke test that runs in seconds, not after a full build.
- Porffor’s WeakRef and finalizer bugs block the lifetime design; we fix them upstream.
- WebAssembly runs on wasmi, an interpreter in the core, on every platform (iOS forbids JIT). Each engine exposes it only if its ArrayBuffers can alias linear memory (docs/platform/webassembly.md).
- Apps like VS Code load arbitrary JS and wasm at runtime, so JIT engines come next, through the same engine interface. First a Node-API addon (V8 under Node, JSC under Bun): JIT numbers without shipping an engine, and the same pages under QuickJS and V8 to diff engine bugs. Then V8 embedded for desktop and Android, and JavaScriptCore on Apple platforms (a system framework, JIT on macOS). A JIT engine’s own WebAssembly replaces wasmi.
- The Node-API addon is on napi-sys, not napi-rs: the engine interface holds raw handles and runs the page from a libuv timer, which napi-rs’s wrappers do not cover. Node owns the loop; a page’s realm is a vm context, for correctness and not isolation (a page shares the host’s trust); its WebAssembly is V8’s; a Worker runs on one of the runtime’s worker threads, in a realm of its own (docs/engines/napi.md).
- Wasm known at build time compiles ahead of time to native code linked into the app (wasm2c, then clang), which iOS allows. Anything else falls back to the runtime engine.
- Parley owns line breaking, baselines, and glyph positions, using the platform’s system fonts.
- Live targets: Parley’s positioned glyph runs are drawn by the platform, one native text view per inline formatting context: CoreText
CTFontDrawGlyphs, DirectWrite and iced’s renderer draw the glyphs; Android draws each run’s text withCanvas.drawTextRunat Parley’s positions, shaped in the run’s whole text with unhinted (linear) advances as Parley measured them, so the platform’s text drawing applies outline text (drawGlyphsnever gets it); kerning breaks only at run boundaries, which are style boundaries where Parley’s shaping breaks too. It falls back todrawGlyphswhere its shaping would not land on Parley’s positions. Measuring and drawing use the same fonts and positions, so they can’t disagree. - Template targets (widgets, CarPlay, Android Auto) serialize text as strings and native text lays it out, because templates accept nothing else.
- niso owns text layout on every platform, on purpose: one inline layout implementation makes inline content (spans across lines, inline boxes and images, ranges, selection, carets) consistent everywhere. Matching SwiftUI’s or Compose’s text is not a goal. Text has to look good, never clip, and use the platform’s fonts and settings. Fix classes of text bugs (measuring with one system and drawing with another, settings applied twice or not at all), not pixel-level edge cases.
- User settings are live inputs, re-resolved without a restart: text size (Dynamic Type, Android font scale including nonlinear scaling) is the root font scale; Bold Text and Android’s Bold text are the platform’s own font resolution, applied in font matching so
getComputedStylekeeps the CSS weights: the system font is matched at the weights UIKit resolves it to (Bold Text moves each up a named instance, Regular to Semibold), and Android’sfontWeightAdjustmentis added to every font’s weight; we encode no weight mapping of our own; Increase Contrast and high-contrast text areprefers-contrast: more; dark mode isprefers-color-scheme; the iOS tint and Material You palette are CSS system colors (AccentColor,AccentColorText) plus custom properties; reduced motion and transparency are their media queries. - Text layout engines (docs/text-driver.md): Parley is the default engine everywhere and stays first-class. A native engine per inline formatting context (TextKit 2,
StaticLayout) behind the same geometry contract comes later as an opt-in, first per app (a host switch), then per block (--niso-text-engine), falling back to Parley where native can’t express the content. Templates measure their text with the native engine at publish time and keep flexible boxes, so widgets stay right when their size or the user’s settings change while the app isn’t running.
Editing
Section titled “Editing”- The core is the editing host for
contenteditable:SelectionandRange,beforeinput/input/composition*events, MutationObserver, andRange.getClientRects()andcaretPositionFromPointfrom core geometry. The bar is ProseMirror and Tiptap unmodified. - One native view per editing host bridges the platform’s text services:
UITextInput+UITextInteraction(iOS),NSTextInputClient(macOS),InputConnection+Magnifier+ floating action mode (Android), TSF (Windows). watchOS uses the system input sheet. <input>and<textarea>withappearance: autostay native text fields.- Clipboard and drag data map to the platform pasteboard, including its HTML flavor. HTML5 drag and drop (
DataTransfer) is supported, not only pointer events. - The mouse is pointer 1 and always active, as Chrome’s is: hosts report it with that id, and
setPointerCapture(1)never throws NotFoundError, so a page that dispatches its own mouse events can capture it.
Accessibility
Section titled “Accessibility”- An AccessKit tree derived from the DOM and ARIA, exposed through AccessKit’s platform adapters. Native controls contribute their own accessibility nodes into it.
Elements
Section titled “Elements”- HTML elements keep their HTML meaning. Where HTML and native semantics match exactly (checkbox, switch, radio, range, text inputs, select, progress, meter, dialog, popover),
appearance: autorenders the native control. - A group of radio buttons (a
fieldsetorrole=radiogroupelement whose radio buttons share a name) is one native control: a row of a few short options is the platform’s segmented control (a segmented Picker on Apple, a connected button group on Android), any other group a list (a menu Picker on Apple, radio button rows on Android). The page places the group; the control fills its box at the platform’s height, and styles on its radio buttons and labels never show. A group that holds more than its radio buttons and their labels, such as a<legend>, a hint or a visible label, keeps it: the control takes only the radio buttons’ area, at the platform’s height and the width of the group’s content, and the rest renders around it as the page styles it. A group where the page hides a radio button to draw its own (opacity: 0,visibility: hidden) or styles one withappearance: nonestays plain radio buttons, as the page draws them. In a browser it stays plain radio buttons. - Our own components are presentation components: the name is a promise, and it holds on every platform in that platform’s style. A FAB is a FAB everywhere. No components that turn into something else per platform.
- A minimum control set exists on every platform, built as a composite where a platform has no native equivalent: button, link, checkbox, switch, radio, segmented control, select/picker, menu, context menu, text field (single-line, multi-line, secure), search field, slider, stepper, date and time picker, progress, activity indicator, alert, sheet, popover, FAB, navigation bar, toolbar, tab bar, sidebar/split view, list with sections and swipe actions, scroll view, grouped form section.
- Each control has a fixed set of CSS properties that map to native modifiers everywhere: colors and
accent-color,border-radius,font,padding, size. Restyling within that set keeps the native control. Other properties are ignored with a dev warning. appearance: automeans a native control (sized as a replaced element);appearance: nonemeans a plain CSS box.- Controls are native wherever an HTML element has a native counterpart. Styling past what the control’s property set covers is the author’s cue to use a plain element (
appearance: noneor adiv), not a reason to draw a lookalike. - Defaults follow the platform, layout follows CSS. The user-agent stylesheet is per platform, so default spacing, type sizes, and control metrics follow native conventions, and authors override them as they like. Layout algorithms (block, inline, flex, grid, centering, positioning) behave like Chromium given the same CSS.
- Platform divergence is explicit and the author’s choice:
@media (platform: …)and(form-factor: …), a JS platform API, and prefixed properties such as-apple-glass-effectthat other platforms ignore. - Pages follow the system’s appearance through standard CSS:
prefers-color-scheme,color-schemeandlight-dark()take the system’s dark mode, and each native control takes its element’s used color scheme. Hosts give pages the platform’s design tokens as custom properties in a host sheet the host replaces when the settings change (niso_dom_set_platform_style):--apple-system-*on Apple (UIKit’s semantic colors, named after WebKit’s-apple-system-*keywords, and the text styles at the user’s Dynamic Type size) and--md-sys-color-*,--md-sys-typescale-*and--md-sys-shape-corner-*on Android (Material’s token names for the web, in the dynamic colors and at the user’s font scale). Pages read them with a fallback,var(--md-sys-color-primary, #6750a4), so the same CSS works in a browser. A page that wants to look native without writing those itself asks for niso’s platform sheet,<meta name="niso-platform-style" content="auto|ios|android|web|off">:--niso-*tokens over both sets of names with web values where neither is set, and a base sheet per platform (colors, type, margins,overflow-wrap: break-wordas native text views wrap), in theniso-platformcascade layer of the author origin where the meta is, so every unlayered rule of the page wins. It is off by default and opt-in by meta rather than in the UA sheet because a plain web page must render as it does in a browser; the meta holds its sheet as a link holds its own, so it follows the meta’s content and goes with the meta. - Pages sit in the safe area unless they ask for
<meta name="viewport" content="viewport-fit=cover">, as on the web. A cover page is laid out under the system bars and keeps its content clear of them withmax(env(safe-area-inset-top), var(--niso-safe-area-inset-top, 0px)): hosts publish the insets in the host sheet because stylo’senv(safe-area-inset-*)is always 0 (repros/stylo-servo-viewport-variants-and-safe-area). - The viewport-percentage variants, once stylo resolves them (it gives all three
vhtoday, same repro): on a page that is not cover, small, large and dynamic all equal the viewport. On a cover page, large is the whole screen, small is the screen less the system bars that can appear, and dynamic is whichever is on screen now, which differs from large only while bars are transient, as in Android’s immersive mode. The keyboard does not change them, as in browsers by default; only<meta name="viewport" content="interactive-widget=resizes-content">would shrink the viewport for it, and supporting that is a follow-up.
Navigation
Section titled “Navigation”- Apps are SPAs. The core has no navigation model; chrome lives in the app’s layout or in portals.
- System back (Android back and predictive back, iOS edge swipe, Escape, Back on desktop) calls
history.back(). - Page transitions use the View Transitions API, implemented natively. A gesture-driven transition for interactive swipe-back can come later.
Renderers
Section titled “Renderers”- One contract: every renderer consumes the mutation stream and keeps its own tree. Live targets update views on commit; template targets serialize on commit. SSR is a normal render.
- Phone, watch, widget, and complication screens are the same components. Live or SSR is a render mode the target picks.
- Renderers place views at the core’s absolute frames and never run their own layout.
- Native owns scrolling; the core sends content size, the renderer sends scroll offset back.
- Box visuals map to native layer properties; custom drawing only when a fixture needs it.
box-shadow,filterandbackdrop-filterreach renderers as one EFFECTS op per box, drawn with the platform’s own effects: SwiftUI shadows, blur and color adjustments; Android’s RenderEffect blur and the Filter Effects color matrices.- A box with a blurring
backdrop-filteris a material: on iOS 26 and later its background is Liquid Glass (glassEffect), tinted with its background color, in its border curve; before that, a blur material under that color. A browser draws the same CSS as a frosted blur, the nearest standard look, so no prefixed property is needed for the common case. Android has no backdrop blur for content in the window, so it draws the translucent background alone. This is box styling, not a control, and stays out of the controls’ fixed property sets. - Animations: the core resolves what animates and how; the native side runs it.
- Native containers (navigation, tabs, sheets, menus, swipe actions) are custom elements rendered as the real container.
- Widget and template content can come from the app or from a server, pushed through the platform’s push update mechanism, in the same format.
Targets
Section titled “Targets”- Apple (iOS, iPadOS, macOS, visionOS, watchOS, tvOS, widgets, Live Activities): SwiftUI, one host in
hosts/apple. UIKit and AppKit only through representables where a benchmark or a platform API forces it, such as the text input view. macOS is native SwiftUI on AppKit, not Mac Catalyst, so its controls are Mac controls. - A host supports every platform its UI toolkit targets from the start, not one first and the rest later: the SwiftUI host builds for iOS, iPadOS, macOS, visionOS, tvOS and watchOS, and CarPlay through templates. Platform differences live in small shims (the toolkit’s types, screen metrics, lifecycle, the frame clock, input, appearance), so the renderer and the engine stay shared and a new platform is a shim, not a fork (docs/hosts/apple.md).
- Android (phone, Wear, TV, widgets via Glance): Compose with Material 3 Expressive.
- Windows: Windows App SDK (WinUI 3).
- Linux: iced.
- Web: the browser’s real DOM, no renderer. Our custom elements ship as web components; platform-prefixed properties are ignored.
- CarPlay, Android Auto: template serialization.
- Embedded Linux: full stack.
- MCUs: our own small no_std DOM behind the same C API, styles resolved at build time, Taffy on device, LVGL sink.
Hard problems to design before building on them
Section titled “Hard problems to design before building on them”- The derived native tree: view flattening, one native text view per inline formatting context (with glyph-to-node hit testing), paint order by stacking context, native parent differing from DOM parent. Frames relative to the native parent. The accessibility tree is derived the same way.
- Semantic values alongside resolved ones: system colors, materials, accent tint, Dynamic Type styles stay dynamic on the native side.
- Environment changes (size, safe area, scale, color scheme, text size, locale/RTL) need a synchronous layout round trip to avoid wobbly live resize.
- Gestures are declarative where they can be:
touch-actionand passive listeners decide native gesture behavior up front, so native scrolling starts without waiting for JS. Non-passive listeners run synchronously on main and maypreventDefault. - Two-way control state (value, selection, focus, IME composition) between native controls and the DOM.
- Android IME composition for the editing host.
- SwiftUI update cost for large trees and per-frame changes: one observable model per node, so a
transformchange invalidates one view. Measure before building on it. - Driving WinUI 3 from Rust.
- Stream compatibility between app and widget/extension versions; widgets decode a tree rendered by the app.
Web platform APIs
Section titled “Web platform APIs”- Reuse before writing. Before implementing a web API, look for a maintained implementation that follows the spec (Rust, C/C++, or the spec’s reference JS) and use it. Write only what touches our DOM, layout and event loop. In use: html5ever, stylo, encoding_rs, url, ureq, tungstenite, wasmi.
- Cryptography is AWS-LC (aws-lc-rs) behind niso-crypto’s byte API, the same on every platform. Platform crypto (CryptoKit, Android’s BoringSSL) can come later as a driver, as storage has.
- Storage is pluggable: the core runs Web Storage and IndexedDB on a driver, SQLite by default, and hosts can hand over their own (docs/storage.md).
- A native app’s document has no URL, so the core gives a document at about:blank the app’s origin for storage, as an initial about:blank document has its creator’s:
https://<app>.app.nisofor the executable’s name, or what the host sets withniso_dom_set_app_origin(iOS and Android use the bundle id or package name). Hosts don’t set a document URL instead: that would changelocationand how relative URLs resolve, only to name the app. Default storage directories are per app for the same reason. - Until one storage agent per origin per process serves every thread, a worker’s IndexedDB is in memory and its own: two drivers never open one SQLite file.
- Native code uses the same API as pages. Client bindings for Rust, Swift, Kotlin, Zig, Go and C are generated from the same WebIDL as the JS glue, and native modules ([NisoModule]) go the other way: native code implementing an IDL interface for pages (docs/modules.md).
anycrosses the C API as its structured serialization (docs/structured-clone.md), which native code decodes withniso_structured_clone_decode. A[NisoHeld] anyis a binding’s own value, which only that binding can read. Where the platform delivers the value, the member also has a native form ([NisoNative]): MessageEvent.data has the serialization that arrived, and data the platform makes itself (a WebSocket’s text and binary frames) as a plain string or ArrayBuffer in the C API’s ordinary types. CustomEvent.detail and ErrorEvent.error have none: script makes them in the binding that reads them (a CustomEvent’s constructor, the exception a script threw), so the C API hands out that binding’s value as it is.- Platform APIs are brought in per host, case by case. The document (DOM, CSS, layout, events, rendering) is always there; each platform API group (fetch, storage, crypto, workers, notifications, media session, …) is a feature a build includes or leaves out. A Python app drawing its own UI may want none of them, a Gradio shim may want fetch, and an app embedding a web app wants them all.
- Native apps get the platform APIs too, through the same C API and generated bindings. Some exist on native platforms only through niso (notifications, media session, Live Activities), so they are not just for pages.
- A host’s own implementation plugs in as a driver behind niso’s API, as storage does, so pages and native callers see the same semantics whichever backend runs. Under a JS runtime (Node, Deno, Bun), the runtime owns threads and the event loop, and the engine’s own per-realm built-ins (WebAssembly, Intl) are used directly.
Process
Section titled “Process”- No generated source, no clever glue. One build script per target.
- “Looks right” has native references wherever the platform draws it. Text is checked for consistency, not against native text: its ink stays inside the box layout gave it, nothing clips, and that holds at the default and large text sizes. Its look is checked coarsely: a sample in each platform text style against SwiftUI’s or Compose’s text in that style, by ink size, stroke weight, sharpness and color, never glyph by glyph. Plain SwiftUI and Compose screens check native controls: stock look, intrinsic size, nothing clipped. Chromium checks only layout and box rendering for the same page and CSS: every element’s border box within 1px, and box-only screenshots nearly pixel-identical. Chromium is never the reference for how text looks. Tests also check each user setting is honored.
- Every merged feature updates the docs in the same branch: the pages docs/CONTRIBUTING-docs.md names for its kind of change, with their “accurate for” stamps moved, and the docs check in
tests/check.shpasses. Reference is generated from the source (IDL, headers, contracts, recorded results) on every site build and never committed. tests/check.shruns everything;--devicesadds the simulator and emulator comparisons. Known differences are listed with their reason and fail the run once they stop happening.- beako (Mac mini) is the verification loop: iOS and watchOS simulators and an Android emulator, with hardware acceleration.
Priorities
Section titled “Priorities”- Modularity comes first. Everything is decoupled: each crate is self-contained behind a written contract (what it owns, its API, its invariants, its threading and lifetime rules), with its own tests, so a fix in one crate cannot break another. Crates do not know about each other’s internals or register themselves in a central list; composition happens at link time, so a build is the set of crates it links. Each web API crate is usable on its own, by any Rust program or runtime, against a small host contract (queue a task, wake, report errors); niso’s DOM is one such host. Contracts change deliberately, through a reviewed change to the contract itself, never as a side effect of a fix.
- Widen what runs: more real apps and test suites working (Bluesky, VS Code for the web, the Svelte playground, Discord, rich-text and code editors), and new native apps built on niso (ihatemyphone, radio, the timer). Each is tracked as a compat or example page with a checked status. No single app is the milestone.
- The compatibility target is real apps that run and look right on every host. WPT is the regression net: the Baseline gate still fails on any regression, but work is not picked to raise its pass count. The corpus is TodoMVC (Svelte, React and Vue), Bluesky’s social-app (its web build), niso’s own docs site (Astro Starlight,
docs/site/on thedocs-studybranch), Excalidraw, and VS Code for the web as the stress test. Each app has a fetch and build script pinned to an exact upstream commit or version and a driver that performs its core flow, run bytests/apps/run.shon each engine, with its status per engine and host in docs/compat/apps.md. When an app hits an API niso is missing, the API gets built, with its WPT tests as the checklist. - Performance and memory are features: cold start, frame time, input latency, memory and binary size are measured per app and per engine, and a regression fails like a test.
App tooling and frameworks
Section titled “App tooling and frameworks”- Frameworks run on niso unchanged: niso implements enough of the web platform (History, Navigation and View Transitions APIs, modules, scheduling, observers) that React, Vue, Svelte, Solid, Angular, plain HTML and their routers and meta-frameworks need no niso adapter. A framework that needs a shim has found a niso bug, fixed in niso.
- Vite is niso’s build tool: a niso Vite plugin (platform files such as
.ios/.android/.web, dev-server wiring to devices) and anisoCLI for dev, build and run. Neither depends on the app’s framework. - Renderers and authoring are separate layers. Hosts render niso’s tree: SwiftUI, Compose, iced, and
@niso/web, a web host written in Svelte with a component per native control. A host’s control components can be overridden. Any app, in any framework, runs on any host.