Document Object Model
Azul's DOM differs from a browser DOM in four places:
- Hierarchy lives separately from node data. The relationships
(
parent,prev_sibling,next_sibling,last_child) are in one array. The content (tag,class, inline CSS, callbacks) is in a parallel array. They're indexed by the same node id. - Both arrays are flat
Vecs in DOM tree order. Parent before children. So a slicedata[self..self.last_child + 1]is the subtree rooted atself. No pointer-chasing to walk a subtree. - The DOM is frozen after
layout()returns. There is noinsertChild, nosetAttribute, no mutation observers. To change the tree, the nextlayout()call returns a newDom. The framework diffs old against new and migrates state across. - CSS is stored in a compact, layout-hot cache. Common enum
properties (
display,position,float,overflow) are bit-packed into a singleu64per node. The numbers and the cold paint properties live in two more arrays. The point is to make the per-node working set small enough that the layout pass stays in L2 instead of round-tripping to RAM. The compact-cache implementation itself is documented separately, in internals/styling/compact-cache.md.
The result is a tree like this:
hierarchy[0..5] data[0..5]
┌───────────────┐ ┌──────────────────────┐
0 │ parent: - │ <body> │ NodeData { ... } │
1 │ parent: 0 │ <div> │ NodeData { ... } │
2 │ parent: 1 │ <span> │ NodeData { ... } │
3 │ parent: 0 │ <p> │ NodeData { ... } │
4 │ parent: 3 │ "text" │ NodeData { ... } │
└───────────────┘ └──────────────────────┘
Indices into both arrays match. The layout engine traverses by index, not by pointer, and reads from compact arrays whose memory layout it controls.
Cache hierarchy
Layout itself isn't algorithmically hard. It's a tree walk plus a lot of if/else. The expensive part isn't the math; it's pulling each node's properties out of memory.
A modern CPU has a tiered memory hierarchy. Cycle counts are approximate but the order of magnitude is right:
- L1 data cache — 32 to 128 KB per core. ~4 cycles to read.
- L2 — 256 KB to several MB per core. ~12 cycles.
- L3 — 4 to 32 MB shared. ~30 to 60 cycles. Doesn't exist on embedded targets.
- Main RAM — gigabytes. ~100 to 300 cycles. A full miss costs more than running 100 instructions.
Layout reads the same per-node fields once per relayout pass. If the working set fits in L2, the second pass is essentially free. If it spills to RAM, every node fetch stalls the pipeline.
The relevant per-node sizes in the layout hot path:
NodeHierarchyItem 32 B parent + 3 sibling/child indices
StyledNodeState 10 B :hover / :focus / :active per node
NodeFlags 4 B contenteditable, tab index, anonymous
compact-cache tier 1 8 B display/position/float/etc bit-packed
compact-cache tier 2 (hot) 68 B width, height, margin, padding, ...
compact-cache tier 2 (cold) 28 B paint-only properties (color, opacity)
compact-cache tier 2b (text) 24 B text-related layout
per-node total (hot) ~150 B
per-node total (warm) ~170 B add cold + text tiers
NodeData (cold during layout) 152 B read once for inline CSS, classes
For 1,000 nodes the layout-hot working set is ~150 KB. That fits in L2 on every desktop chip and most embedded ones. For 10,000 nodes it's ~1.5 MB, still L2 on a modern Apple/Intel core. For 100,000 nodes it's ~15 MB, which is L3 on desktop and main memory on embedded.
The numbers indicate when virtual views, lazy panels, and other ways to keep the rendered subtree small start to matter. See Virtual Views.
What's in a node
Each node is split across the two parallel arrays.
NodeHierarchyItem carries four indices into the same array — the
node's parent, its previous and next siblings, and its last
descendant:
pub struct NodeHierarchyItem {
pub parent: usize, // 0 means "no parent"
pub previous_sibling: usize,
pub next_sibling: usize,
pub last_child: usize, // index of last descendant
}
Because children sit contiguously after their parent in tree order,
data[self_idx ..= last_child] is the whole subtree rooted at
self_idx. No pointer-chasing, no recursion needed to copy a subtree.
NodeData carries everything that defines a single node:
pub struct NodeData {
pub node_type: NodeType, // HTML tag or leaf (Text/Image/Icon/VirtualView)
pub callbacks: CoreCallbackDataVec, // event handlers; empty for ~80% of nodes
pub style: Css, // inline CSS with implicit :scope, INLINE priority
pub flags: NodeFlags, // tab index, contenteditable, anonymous
pub accessibility: Option<Box<AccessibilityInfo>>, // ARIA, only on accessible nodes
pub extra: Option<Box<NodeDataExt>>, // attributes, dataset, menus, virtual view, ...
}
style: Css is the same struct the cascade uses everywhere else;
inline rules carry their conditions (:hover, :focus, @theme dark,
@os macos) directly. The two Option<Box<...>> fields keep the
common case small — a typical paragraph or div pays nothing for the
accessibility or extras boxes. About 95% of nodes never trigger the
extra allocation.
A function of state
It helps to remember what the browser DOM was originally for. In the
1990s, web pages arrived over slow modems as a stream of HTML, and
the browser had to render while the document was still being
received. document.write injected new nodes mid-parse;
appendChild, removeChild, and the rest of the mutation API let
scripts patch the tree as more bytes arrived. Mutability wasn't a
design goal — it was a constraint of streaming over a 14.4k modem.
When SPAs took over, the streaming use case mostly went away, but the mutation API stayed. React's contribution was to talk users out of using it: model the UI as a function of state, render the whole tree on every change, and let a reconciler diff old against new. Vue, Solid, Svelte, and Elm all converged on the same shape. The browser's imperative DOM became an implementation detail the framework hid.
Azul has no streaming parser to support and no legacy mutation API to
preserve, so it makes „UI is a function of state“ the rule from the
start. The Dom returned from layout() becomes the framework's
copy:
- A callback returns
Update::RefreshDom. - The framework re-invokes the layout function.
- A fresh
Domis built from the application data. - The framework diffs the new tree against the previous one and migrates focus, scroll, dataset, and merge-callback state across matched nodes.
There is no handle to the live tree, no insertChild /
setAttribute / mutation observer surface. Removing the mutation API
has two payoffs: half the bugs that show up in any non-trivial UI
come from „this listener saw stale state because something else
mutated the tree first,“ and a tree the framework owns is far
easier to lay out incrementally than a tree the application can
change at any time.
The reconciliation algorithm — what counts as „matching“ old and new nodes, what migrates, what fires lifecycle events — is documented in Reconciliation.
State that has to survive a tree rebuild (a video decoder, a GL texture, the cursor inside a focused input) doesn't live in the tree shape. It hangs off the node as a dataset. See Datasets and Merge Callbacks.
Building DOMs
The recursive Dom value
Dom is the form actually constructed in user code:
pub struct Dom {
pub root: NodeData,
pub children: DomVec,
pub css: CssVec,
pub estimated_total_children: usize,
}
A Dom is a subtree: a root NodeData, its children, and any
component-level stylesheets attached via .with_component_css(Css).
The framework
flattens the recursive form into the parallel NodeHierarchyItem /
NodeData arrays once, at the start of the cascade. Every builder
method on Dom (with_class, with_callback, with_css) is a
shorthand that delegates to the same method on self.root.
Node constructors
Each HTML element has a Dom::create_<tag>() constructor. Most are
const fn and don't allocate until a child is added:
use azul::prelude::*;
let _ = Dom::create_div();
let _ = Dom::create_section();
let _ = Dom::create_article();
let _ = Dom::create_main();
let _ = Dom::create_nav();
let _ = Dom::create_header();
let _ = Dom::create_footer();
Text-bearing constructors take a string and wrap a Text child
inside the element:
use azul::prelude::*;
let _ = Dom::create_h1_with_text("Title");
let _ = Dom::create_h2_with_text("Section");
let _ = Dom::create_p_with_text("A paragraph.");
let _ = Dom::create_span_with_text("inline");
let _ = Dom::create_strong_with_text("important");
let _ = Dom::create_code_with_text("println!()");
let _ = Dom::create_text("standalone text node");
NodeType (in core/src/dom.rs) lists every variant. The set covers
all HTML elements plus the SVG subset plus four leaf types: Text,
Image, Icon, and VirtualView.
For elements with non-trivial accessibility surface, the primary
constructor takes an a11y struct as a required argument. There's a
matching _no_a11y variant that opts out explicitly. The longer
name on the opt-out is the point: it signals that a11y was skipped
on purpose, and it makes the absence visible during code review —
the soft-force pattern.
use azul::prelude::*;
// Primary form: a11y is part of the call signature.
let save = Dom::create_button("Save", SmallAriaInfo::label("Save document"));
// Explicit opt-out, longer name.
let ok = Dom::create_button_no_a11y("OK".into());
Most interactive elements use the generic SmallAriaInfo (label,
role, description). A few (<progress>, <meter>, <dialog>)
have type-specific structs because their a11y surface needs more
than that. Static, non-interactive elements (div, span, p,
the headings, inline text formatters) don't take a11y info — their
role is implicit from the element type.
See Accessibility for the full list of elements that follow the soft-force pattern, the type-specific aria structs, and how the framework translates them into the platform-specific accessibility trees (UIA, AT-SPI, NSAccessibility).
IDs, classes, attributes
use azul::prelude::*;
let _ = Dom::create_div()
.with_id("sidebar".into())
.with_class("panel".into())
.with_class("scrollable".into())
.with_attribute(AttributeType::AriaLabel("notification banner".into()))
.with_attribute(AttributeType::Lang("en".into()));
IDs and classes aren't separate fields. They're stored as
AttributeType::Id and AttributeType::Class entries in the node's
attribute list. The selector .panel { ... } matches every node
whose attribute list contains Class("panel").
AttributeType (in core/src/dom.rs) is a strongly-typed enum:
Href, Src, Alt, AriaLabel, Required, MaxLength(i32),
ContentEditable(bool), and so on. There's a Custom fallback for
arbitrary name="value" pairs. Attributes aren't inline CSS — they
feed accessibility, attribute selectors like [lang="en"], and
HTML/XML serialization.
Adding children
Three ways to attach children:
use azul::prelude::*;
// 1. One at a time. Each call grows .children by one.
let a = Dom::create_div()
.with_child(Dom::create_h2_with_text("Title"))
.with_child(Dom::create_p_with_text("Body"));
// 2. Replace the child vec wholesale.
let kids: DomVec = vec![Dom::create_span_with_text("x"), Dom::create_span_with_text("y"), Dom::create_span_with_text("z")].into();
let b = Dom::create_div().with_children(kids);
// 3. Collect from an iterator into a parent.
let c: Dom = (0..3).map(|i| Dom::create_li_with_text(format!("Item {}", i))).collect();
// Produces a NodeType::Div containing three <li> children.
with_child calls add_child, which pushes onto the underlying
Vec and updates estimated_total_children. That's amortised O(1)
per call. with_children(DomVec) is one allocation total.
estimated_total_children is maintained by every add_child and
set_children call. The framework reads it to pre-size the flat
arena during conversion. If children is mutated directly, call
fixup_children_estimated() before returning.
Defining a clipping path
Two public mechanisms cover the common cases:
with_clip_mask(ImageMask)takes a raster alpha mask. Use it for irregular shapes that already exist as image data.with_css("clip-path: ...;")parses the CSS property into the node's inline-CSS list. Applied during the cascade.
use azul::prelude::*;
fn build(mask: ImageMask) -> Dom {
let raster = Dom::create_image(ImageRef::null_image(0, 0, RawImageFormat::R8, U8VecRef::from(&[][..])))
.with_clip_mask(mask);
let css_form = Dom::create_div()
.with_css("clip-path: circle(40px at 50% 50%);");
Dom::create_body().with_child(raster).with_child(css_form)
}
A clip-path set on a parent applies to every descendant.
Inline CSS
The primary way to attach CSS is .with_css(...) on the node itself.
The method takes a string, parses it through the same pipeline the
cascade uses elsewhere, and stores the result in
NodeData::style: Css.
use azul::prelude::*;
let item = Dom::create_div().with_css("
color: blue;
font-size: 14px;
:hover { color: red; }
@theme dark { color: white; background: #222; }
");
The parsed rules carry their conditions directly: :hover,
:focus, :active, @os, and @theme blocks all live inside the
same Css value. Conditions are re-evaluated per frame, so
@theme dark { ... } flips when the user toggles dark mode without
any re-layout. Inline rules are tagged rule_priority::INLINE so
they win the cascade against author CSS.
After the recent unification, the inline store is a regular Css —
the legacy css_props: CssPropertyWithConditionsVec field is gone.
with_css_props(vec) still works as a compatibility shim that maps
each property to a single-declaration rule at INLINE priority.
Component-level stylesheets
Reusable components ship a parsed stylesheet that travels with the
subtree. Attach it on the component's root with .with_component_css(Css):
use azul::prelude::*;
let widgets = Css::from_string(".panel { padding: 8px; }".into());
let panel: Dom = Dom::create_div()
.with_class("panel".into())
.with_component_css(widgets);
A browser cascades every stylesheet against every node in one global
pass — .panel { ... } in one tab can match a .panel in any iframe
that imports the same stylesheet, and changes there force a global
restyle. Azul's component CSS travels with the subtree. The
framework merges every component-level Css together when it
flattens the tree, but the rules a component ships only have a
chance to match the nodes the component itself owns. Anything
outside the component's subtree is invisible to its selectors. This
is a soft scope (the framework doesn't enforce a Shadow-DOM
boundary), but it follows from the way components label their roots
and avoids the cross-component restyle storms a global cascade
produces. For hard scoping, write selectors that nest under the
component's root class.
User-level theming sits at the outermost layer: the system
@theme dark block, the system:* color keywords, and the optional
end-user ricing file all target the framework-wide hooks. Component
CSS doesn't fight user theming because the two layers target
different selectors. See Components for the
component-pack model and Theming for the full
theming model and the AZ_RICING opt-out.
The cascade runs once, after the LayoutCallback returns.
NodeData::style: Css and Dom::css: CssVec are opaque state
during layout(). The framework collects the rules at the end of
the callback, sorts by (priority, specificity), and walks the tree
once to fill the compact cache. Selector matching, inheritance, and
the compact-cache build all happen there. CSS work inside layout()
is cheap because each call is just a parse and a push. For the
internal cache layout that the layout engine reads, see
internals/styling/compact-cache.md.
Inside the layout callback
A layout() callback receives application data and a
LayoutCallbackInfo describing the window. Returning a Dom
finishes the pass; the framework reconciles, lays out, and renders.
use azul::prelude::*;
struct AppModel {
user_name: String,
locale: Locale,
}
extern "C" fn layout(data: &mut RefAny, info: LayoutCallbackInfo) -> StyledDom {
let model = match data.downcast_ref::<AppModel>() {
Some(m) => m,
None => return StyledDom::default(),
};
let strings = Strings::for_locale(model.locale);
// Window-aware layout: switch to a single-column layout below 768px.
let body = if info.window_width_less_than(768.0) {
Dom::create_body()
.with_css("display:flex; flex-direction:column;")
.with_child(navbar_compact(&model.user_name, &strings))
.with_child(content_area(&strings))
} else {
Dom::create_body()
.with_css("display:grid; grid-template-columns:240px 1fr;")
.with_child(sidebar(&model.user_name, &strings))
.with_child(content_area(&strings))
};
body.with_component_css(app_stylesheet()).style_dom()
}
The LayoutCallbackInfo exposes everything needed to make the
returned Dom adapt to the running window. Responsive sizing
(window_width_less_than, window_width_between, window_height_*,
and the raw get_window_width / get_window_height) covers the
per-tree branch cases (a hamburger menu vs a sidebar) — the
per-property cases (@media, @theme) are handled by inline CSS.
The framework re-invokes layout() whenever the window crosses a
breakpoint, the system theme flips, or a route switch fires, so the
width branch is always re-evaluated against the live window. The
callback can read info.relayout_reason() to find out why it was
called — Resize, ThemeChange, RouteChange, RefreshDom, or
Initial — and skip work that doesn't need to repeat (analytics
fetches, locale-pack loading) when the trigger was just a resize.
Other helpers: get_dpi_factor returns 1.0 / 2.0 / etc. for asset
selection; get_active_route() / get_route_param(key) for
router-driven trees; get_image(name) for registered images;
get_system_style() for the current SystemStyle snapshot;
get_gl_context() for canvas-backed nodes; get_system_fonts() for
font availability checks (CJK / RTL fallbacks).
A worked example covering window-size, DPI, theme, route, and Fluent localization in a single layout pass:
use azul::prelude::*;
use azul::desktop::fluent::{FluentLocalizerHandle, FluentFormatArg};
struct AppModel {
user_name: String,
locale: String, // BCP-47, e.g. "fr-FR"
localizer: FluentLocalizerHandle,
unread_count: u32,
}
extern "C" fn layout(data: &mut RefAny, info: LayoutCallbackInfo) -> StyledDom {
let model = match data.downcast_ref::<AppModel>() {
Some(m) => m,
None => return StyledDom::default(),
};
// i18n: a localized greeting + a pluralized inbox count.
let greeting = model.localizer.translate(
model.locale.as_str().into(),
"greeting".into(),
Some(&[FluentFormatArg::str("name", &model.user_name)].into()),
);
let inbox = model.localizer.translate(
model.locale.as_str().into(),
"inbox-count".into(),
Some(&[FluentFormatArg::num("count", model.unread_count as i64)].into()),
);
// DPI-aware logo: prefer the @2x variant on Retina/HiDPI screens.
let logo = if info.get_dpi_factor() >= 1.5 { "logo@2x" } else { "logo" };
let logo_img = info.get_image(&logo.into())
.map(Dom::create_image)
.unwrap_or_else(Dom::create_div);
// Theme-aware accent color picked outside CSS (for a value the
// cascade can't reach — e.g. a canvas paint color).
let accent = match info.theme {
WindowTheme::DarkMode => "#79b8ff",
WindowTheme::LightMode => "#0046bf",
};
// Route-driven content: /settings vs /inbox vs default.
let main = match info.get_route_pattern().as_str() {
"/settings" => settings_page(&model),
"/inbox" => inbox_page(&model, &inbox),
_ => home_page(&model, &greeting),
};
// Window-size-driven layout: hamburger nav under 768px, sidebar above.
let shell = if info.window_width_less_than(768.0) {
Dom::create_body()
.with_css("display:flex; flex-direction:column;")
.with_child(top_bar(logo_img, accent))
.with_child(main)
} else {
Dom::create_body()
.with_css("display:grid; grid-template-columns:240px 1fr;")
.with_child(sidebar(logo_img, &greeting, accent))
.with_child(main)
};
shell.with_component_css(app_stylesheet()).style_dom()
}
The output is a StyledDom (dom.style_dom() runs the cascade and
returns the framework-owned form). Returning it hands ownership to
the framework, which reconciles against the previous frame and
schedules layout + paint.
Routing
A multi-page app registers a layout callback per URL pattern on the
AppConfig — the framework picks the right one for the active
route and re-runs it on switch_route:
use azul::prelude::*;
extern "C" fn layout_home(_: &mut RefAny, _: LayoutCallbackInfo) -> StyledDom { todo!() }
extern "C" fn layout_user(_: &mut RefAny, info: LayoutCallbackInfo) -> StyledDom {
let id = info.get_route_param("id").map(|s| s.as_str()).unwrap_or("");
Dom::create_h1_with_text(format!("User #{}", id)).style_dom()
}
fn main() {
let mut config = AppConfig::create();
config.add_route("/", layout_home);
config.add_route("/user/:id", layout_user);
let app = App::create(initial_data, config);
app.run(WindowCreateOptions::new(layout_home));
}
A :name segment captures the path component as a parameter
readable via info.get_route_param("name"). On desktop the route
is in-memory state; on a web build the same routes also map to
HTTP endpoints with history.pushState() integration.
A user callback navigates with CallbackInfo::switch_route —
info.set_route_param(key, value) modifies a single param in place
without changing the active pattern:
extern "C" fn open_user(data: RefAny, mut info: CallbackInfo) -> Update {
let id = match data.downcast_ref::<u64>() {
Some(i) => *i,
None => return Update::DoNothing,
};
let params = vec![StringPair {
key: "id".into(),
value: id.to_string().into(),
}].into();
info.switch_route("/user/:id".into(), params);
Update::RefreshDom
}
The framework swaps the active layout callback on the next frame and reconciles the new tree against the previous one. See Routing for the full pattern syntax, multi-route layouts, and the web-vs-desktop differences.
Parsing from XHTML
Dom::create_from_parsed_xml is the public entry point. Given an
Xml value, it returns a Dom ready to return from layout():
use azul::prelude::*;
let xml_text = "";
let parsed = Xml::from_str(xml_text.into()).unwrap();
let dom: Dom = Dom::create_from_parsed_xml(parsed);
The XML parser walks <html><head><style>...</style></head><body>...</body>,
parses each <style> block into a Css, and attaches it scoped to the
node it was found inside. The cascade runs on the next layout pass like
any other DOM.
ComponentMap is the registry of XML-defined components. See
Components for how the framework
looks up <card title="..."/> against a registered library.
Parsing from SVG
The same XML pipeline accepts <svg> tags inside the body and turns
them into vector nodes that render alongside the rest of the Dom.
No extra wiring is required: the parser recognises the SVG namespace,
walks the geometry, and stamps an SvgNodeData on each shape. A
clip-mask attribute on an SVG element resolves the same way as a CSS
clip-path: (see Defining a clipping path
above).
use azul::prelude::*;
let xhtml = r#"
<html>
<body>
<svg viewBox="0 0 100 100" width="200" height="200">
<circle cx="50" cy="50" r="40" fill="#1d4f8b"/>
<rect x="10" y="10" width="40" height="40" fill="#5dade2"/>
</svg>
</body>
</html>
"#;
let parsed = Xml::from_str(xhtml.into()).unwrap();
let dom: Dom = Dom::create_from_parsed_xml(parsed);
The same dom is renderable without a window. Run the binary with
AZ_BACKEND=headless and the framework rasterises into an in-memory
framebuffer instead of opening a window. Combine with
AZ_DEBUG=<port> to drive take_screenshot from a shell script and
get a base64 PNG back. That's the path snapshot tests, PDF export,
and CI machines without a display server use.
For the full SVG geometry model (paths, tessellation, GPU
tessellated nodes), the standalone Svg::from_string parser that
returns a RawImage, and the GPU stroke pipeline, see
SVG. For the headless backend in detail, see
Headless Rendering.
Callbacks
use azul::prelude::*;
struct Counter { value: i64 }
extern "C" fn on_click(mut data: RefAny, _info: CallbackInfo) -> Update {
let mut c = match data.downcast_mut::<Counter>() { Some(c) => c, None => return Update::DoNothing };
c.value += 1;
Update::RefreshDom
}
fn build(state: RefAny) -> Dom {
Dom::create_button_no_a11y("+1".into())
.with_callback(EventFilter::Hover(HoverEventFilter::MouseUp), state, on_click)
}
with_callback(filter, data, callback) attaches a RefAny and a
function pointer. The handler fires when the matching event reaches
the node. The callback returns an Update that tells the framework
whether to re-run layout, re-render, or do nothing.
What the callback can actually do — read the dataset, query the hit-test, dispatch to siblings, focus another node, schedule a timer, post a thread message — lives in Callbacks. Event filtering and propagation order are in Events and Input.
The framework's reconciler matches new nodes against old ones when a fresh tree is returned. Cursor position, focus, and dataset state migrate across the diff for matched nodes. See Reconciliation.
Datasets
with_dataset(OptionRefAny) attaches arbitrary user data to a node.
Callbacks read it via CallbackInfo::get_dataset. The dataset is the
canonical place for UI-layer state. The cursor inside an input. The
expansion flag on a tree-view row. A marker struct that says „I am the
save button“ so a generic callback can dispatch. See
Datasets for the navigation patterns.
For state that must survive a subtree rebuild, pair the dataset with
with_merge_callback. The framework calls it with the old and new
RefAny values during reconciliation. Heavy resources can move from
the old node to the new one. See Merge Callbacks
for a worked FFmpeg example.
Virtual Views
A VirtualView is a node whose contents come from a separate callback
that runs only when the framework needs them. Use it for infinite lists,
lazy panels, and embedded sub-DOMs that own their own scroll math. The
callback receives a VirtualViewCallbackReason (InitialRender,
DomRecreated, BoundsExpanded, EdgeScrolled(_),
ScrollBeyondContent). Use the reason to skip work when the call is
just a parent re-render. See Virtual Views for
the rendered-vs-virtual coordinate model and a virtualised-table
walkthrough.
Debugging
Run the binary with AZ_DEBUG=<port> set and App::create starts
an HTTP debug server on that port. It accepts JSON commands and
returns JSON responses. The inspector sees the same tree the
renderer is about to draw, so a query reflects what's on screen.
AZ_DEBUG=8765 cargo run --bin my_app
# Synchronous queries:
curl -X POST http://localhost:8765/ -d '{"type":"get_state"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_dom_tree"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_node_hierarchy"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_node_css_properties", "selector":".panel"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_layout_tree"}'
curl -X POST http://localhost:8765/ -d '{"type":"get_display_list"}'
# Synthesised events (dispatched into the same callback path real input uses):
curl -X POST http://localhost:8765/ -d '{"type":"click", "selector":".button-primary"}'
curl -X POST http://localhost:8765/ -d '{"type":"text_input", "text":"hello"}'
The synthesised events go through the exact same dispatch path as
real input. A scripted click runs the same hit test, the same
event filters, and the same callback as a user mouse click. That
makes the debug server the basis for end-to-end tests: drive the
app from a shell script or a Python harness, assert on the JSON
responses, and the result is an integration test that exercises the
real layout, the real callbacks, and the real reconciliation pass.
The test pattern, the assertion vocabulary, and the CI recipe are
covered in End-to-End Testing.
For tests that don't need a window (snapshot tests, PDF export, CI
machines without a display server), the same debug API is also
reachable in Headless Rendering, which
runs the full layout and rendering pipeline into a Vec<u8>
framebuffer.
The get_node_hierarchy response carries a component field for
each node, allowing navigation back to the component that produced
it. The inspector uses it to draw a Component Tree alongside the DOM
Tree:
{
"index": 17, "node_type": "div", "tag": "card",
"id": null, "classes": ["card", "card--info"],
"parent": 12, "children": [18, 19, 20],
"component": {
"component_id": "shadcn:card",
"data_model": { "title": "First", "body": "alpha" }
},
"rect": { "x": 16.0, "y": 16.0, "width": 320.0, "height": 88.0 },
"events": ["MouseUp"], "tab_index": 0
}
Picking a node in the DOM Tree surfaces the component that produced it, and (if its render function lives in a registered library) the inspector links back to the source. See Components for how a library wires its components into the registry.
Coming Up Next
- Callbacks — What
CallbackInfoexposes, dataset reads, focus/scroll dispatch - Routing — URL patterns, route params, and per-route layout callbacks
- Reconciliation — Diffing, restyle scope, and damage-rect repaint
- Datasets — Attaching state to a node for navigation and per-instance state
- Components — Reusable UI fragments — named functions of (args) -> Dom
- Styling with CSS — Stylesheets, selectors, and the cascade
- Theming —
@theme dark,system:*colors, and end-user ricing