docs/advanced/devtools
Capabilities · Diagnostics

DevTools

Track down layout, hit-testing, rendering and performance issues with a one-line overlay, a standalone panel and a bounded, structured Console.

9 min read

In-window inspector

During development, attach the overlay to your root. On hover it outlines the node’s rect with a dashed box and shows its size and component name. The overlay is pass-through (it doesn’t intercept clicks or scrolling), and hovering only triggers paint-level updates, never a relayout.

main.zig
fn mountUi(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
    const root = try mountProductUi(cx, scope);

    // Development only: hover highlight with size + component name.
    _ = try ui.devtools.overlay.attach(cx, scope, root, .{});
    return root;
}

The overlay’s state lives on the scope you pass; when that Scope is disposed, the overlay goes with it. Use it in the product window to answer “who owns this space?”; use the panel for full diagnostics. Both read the real Node / Cx state, so there is no mirror model to maintain for debugging.

Diagnose by symptom

SymptomCheck first
Misaligned contentParent rect, padding, gap, direction (Elements -> Layout)
Empty areas are clickableThe hit node’s size and hit_behavior
Text data changed, screen didn’tDid you mutate TextProps fields without calling setText / setTextContent? Those APIs compare and mark sizing / render dirty themselves
Layout ignores a new widthAssigning node.style directly marks nothing dirty; use node.setStyle(alloc, .width, v), which picks the right dirty level per field
Gets slower over timeRepeated mounts, Effects not released with their Scope, per-frame allocation (Performance -> Summary / Rebuild)

The DevTools panel

For the full Elements, Components, Console and Performance views, use ui.devtools.mountPanel(cx, target, opts). The panel has its own Cx and observes a separate target Cx; the usual setup puts it in its own window with MultiWindowApp so it never covers the product UI. The target must stay alive while the panel is in use.

main.zig
const std = @import("std");
const ui = @import("ui");
const zenit_app = @import("zenit_app");

// mountProductUi: your app's ordinary mount function (see above).
var g_target_cx: ?*ui.Cx = null;

fn mountDevTools(cx: *ui.Cx, scope: *ui.Scope) anyerror!*ui.Node {
    _ = scope;
    const target = g_target_cx orelse return error.TargetNotReady;
    return ui.devtools.mountPanel(cx, target, .{ .title = "My App DevTools" });
}

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();

    var application = zenit_app.MultiWindowApp.init(gpa.allocator(), .{});
    defer application.deinit();

    const product = try application.createWindowWith(.{
        .window = .{ .width = 900, .height = 640, .title = "My App" },
    }, mountProductUi);
    g_target_cx = product.cx;

    const tools = try application.createWindowWith(.{
        .window = .{ .width = 760, .height = 560, .title = "DevTools" },
    }, mountDevTools);
    _ = ui.devtools.setViewMode(tools.cx, "performance"); // optional start tab

    try application.run();
}

This is how the repo’s zig build devtools-probe is structured. ui.devtools also exposes setViewMode, setTreeFilter, setConsoleFilter and friends so probes and E2E tests can drive the panel programmatically.

Zenit DevTools Elements panel with a box selected and its layout details
The live UI tree on the left; select a node and the Layout tab on the right shows its computed rect, padding, sizing and flex properties. The hand cursor in the shot is the harness’s virtual cursor.

Four views, six detail tabs

ViewAnswers
ElementsWhat is the real node hierarchy: tag / id / text, layout and hit area?
ComponentsWhich nodes form component boundaries, and where are their state and owner Scope?
ConsoleWhich structured logs did the target Cx capture, and was anything evicted or dropped?
PerformanceIs the target rendering or idle? What do layout / render / cache / interaction cost?

With an Elements / Components row selected, switch between Layout, Style, State, Events, Render and Trace. Some Style values are live-editable; Render / Trace show why a node went dirty and its recent events. Tree search matches tags, #id and component names. With ui.devtools.source_link configured, component rows can jump to their definition in your editor.

Console logging

Every ui.Cx owns a thread-safe, bounded Console. One call can print to the terminal and keep a structured event for DevTools and the E2E harness; history captured before the panel opened shows up too.

logging.zig
const log = cx.console();

log.info("application ready", .{});
log.scoped("network").warn("retry {d}", .{attempt});

// Plain level methods don't record a call site; writeAt does.
log.writeAt(.err, @src(), "save failed: {s}", .{@errorName(err)});

Levels are debug, log, info, warn and err. Browser-console counterparts are there too: group / groupEnd, count, time / timeEnd, assert, trace, inspect and table.

Panel areaPurpose
ClearClears the target’s Console store itself, not just the view.
FilterCase-insensitive match on message and scope; combines with the level filter.
LevelsToggle Debug / Log / Info / Warn / Error in any combination.
Log listLevel, scope, message, group indentation and optional source location; auto-follow pauses once you scroll away from the bottom.
Status barshown / captured / evicted / dropped: tells filtering, eviction and loss apart.
Zenit DevTools Console with debug, info, warn and error entries
The logs reached the target Cx’s bounded store before the panel opened; scope, level and source location are kept.
Zenit DevTools Console filtered to the network scope
Filter and Levels combine locally in DevTools; nothing is removed from the target’s store.
A real two-window app: the virtual cursor switches from Elements to Console, focuses Filter and types network. Recorded straight from the DevTools window’s Metal drawable.

Plain level methods don’t record a call site. To click a log line and land in your editor, use writeAt(level, @src(), …) and set the source root and editor command with ui.devtools.source_link.configure (default: code --goto).

Configuring capture

main.zig
const app = try zenit_app.App.init(allocator, .{
    .console = .{
        .terminal_level = .info, // null disables the terminal sink
        .capture_level = .debug, // null disables in-memory capture
        .max_entries = 10_000,
        .max_bytes = 8 * 1024 * 1024,
        .max_entry_bytes = 64 * 1024,
    },
});

Without an explicit console, zenit_app picks defaults by build mode:

Build modeTerminalCapture
Debug.debug.debug
ReleaseSafe.info.info
ReleaseFast / ReleaseSmall.warnnull (off)

The Console mirrors the browser console’s capture-and-view side; it is not a Zig / JavaScript REPL. Groups currently indent but can’t be collapsed interactively, and table falls back to text. The full API, threading and lifetime rules, and harness examples are in the repo’s docs/CONSOLE.md.

Performance: idle vs. stuck

The Performance view doesn’t freeze on “the last N rendered frames”. It keeps observing the target in 100 ms wall-clock buckets (64 of them, a rolling window of about 6.4 s) and computes FPS as the mean of the last 10 buckets (about 1 s). When the target produces no frame for more than 0.7 s, the readout says FPS: idle (rendering minimized). When nothing changes, the framework lowers the frame rate as far as it can to save power; this is not a stall.

100MS BUCKETS
After frames stop, the chart keeps moving and records zeros; the readout drops as empty buckets arrive and flips to idle after 0.7 s. It never passes off a stale value as current.
Zenit DevTools Performance showing the idle FPS readout with timing metrics
The static target has stopped rendering; DevTools refreshes itself at about 10 Hz so the chart keeps moving, and layout, render, cache, focus and interaction metrics stay readable.
ReadoutMeaning
FPS + rolling chartWall-clock buckets; a bucket with no frames records 0 and draws as a neutral baseline, never a stale value
TimingCPU wall time of the target’s last frame: layout, render-command generation and so on
Summary / InteractionHit registry, mouse-hit, redraw streak, focus and interaction rebuilds
Cache / RebuildRetained-cache hits / misses, full / partial rebuilds

Test entry points

Terminal
zig build test-headless
zig build test-ui
zig build test-render
zig build hello-button

Before release

  • ✓

    Turn off debug UI. Remove the inspector overlay, or gate it behind a debug-only config.

  • ✓

    Verify input in a real window. Keyboard, IME, clipboard and VoiceOver.

  • ✓

    Run the build gates. The headless, UI and render test steps.

  • ✓

    Check the Console policy. Release terminal level and capture are what you intend, and logs carry no sensitive data.

  • ✓

    Pin versions. Lock the Zenit revision and record the target Zig version.

zenit · Dual-licensedFree for open-source projects under GPL-3.0-only; closed-source or commercial products need a commercial license.Contact the author: zongyi.xzy#gmail.com (replace # with @)zenit 4c50245 2026-10-01