---
title: "DevTools | zenit Zig UI Docs"
description: "Track down layout, hit-testing, rendering and performance issues with a one-line overlay, a standalone panel and a bounded, structured Console."
url: https://ui.surf/zenit/docs/advanced/devtools
language: en
alternate_zh: https://ui.surf/zenit/zh/docs/advanced/devtools.md
alternate_es: https://ui.surf/zenit/es/docs/advanced/devtools.md
alternate_ja: https://ui.surf/zenit/ja/docs/advanced/devtools.md
alternate_ko: https://ui.surf/zenit/ko/docs/advanced/devtools.md
alternate_fr: https://ui.surf/zenit/fr/docs/advanced/devtools.md
alternate_de: https://ui.surf/zenit/de/docs/advanced/devtools.md
project: zenit v0.1.0-alpha (Zig 0.15.2, macOS)
source: https://github.com/version-next/zenit
---

# DevTools

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

## 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`

```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

| Symptom | Check first |
| --- | --- |
| Misaligned content | Parent rect, padding, gap, direction (Elements -> Layout) |
| Empty areas are clickable | The hit node’s size and `hit_behavior` |
| Text data changed, screen didn’t | Did you mutate TextProps fields without calling `setText` / `setTextContent`? Those APIs compare and mark sizing / render dirty themselves |
| Layout ignores a new width | Assigning `node.style` directly marks nothing dirty; use `node.setStyle(alloc, .width, v)`, which picks the right dirty level per field |
| Gets slower over time | Repeated 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](https://ui.surf/zenit/docs/advanced/multi-window) so it never covers the product UI. The target must stay alive while the panel is in use.

`main.zig`

```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](https://ui.surf/zenit/media/devtools-elements.png?v=1c99ec3edc)](https://ui.surf/zenit/media/devtools-elements.png?v=1c99ec3edc)

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

| View | Answers |
| --- | --- |
| **Elements** | What is the real node hierarchy: tag / id / text, layout and hit area? |
| **Components** | Which nodes form component boundaries, and where are their state and owner Scope? |
| **Console** | Which structured logs did the target Cx capture, and was anything evicted or dropped? |
| **Performance** | Is 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`

```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 area | Purpose |
| --- | --- |
| **Clear** | Clears the target’s Console store itself, not just the view. |
| **Filter** | Case-insensitive match on message and scope; combines with the level filter. |
| **Levels** | Toggle Debug / Log / Info / Warn / Error in any combination. |
| **Log list** | Level, scope, message, group indentation and optional source location; auto-follow pauses once you scroll away from the bottom. |
| **Status bar** | shown / captured / evicted / dropped: tells filtering, eviction and loss apart. |

[![Zenit DevTools Console with debug, info, warn and error entries](https://ui.surf/zenit/media/devtools-console.png?v=1411f611cd)](https://ui.surf/zenit/media/devtools-console.png?v=1411f611cd)

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](https://ui.surf/zenit/media/devtools-console-filter.png?v=acfa3d9446)](https://ui.surf/zenit/media/devtools-console-filter.png?v=acfa3d9446)

Filter and Levels combine locally in DevTools; nothing is removed from the target’s store.

[Video](https://ui.surf/zenit/media/devtools-console.mp4?v=05e8c163e3)

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`

```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 mode | Terminal | Capture |
| --- | --- | --- |
| `Debug` | `.debug` | `.debug` |
| `ReleaseSafe` | `.info` | `.info` |
| `ReleaseFast / ReleaseSmall` | `.warn` | `null` (off) |

> WARNING
> 
> **Release builds don’t capture by default.** Under ReleaseFast / ReleaseSmall, DevTools sees no history. If a production build needs it, set `capture_level` explicitly, and keep tokens, passwords and personal data out of your logs.

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](https://ui.surf/zenit/media/devtools-performance.png?v=41f18a12a0)](https://ui.surf/zenit/media/devtools-performance.png?v=41f18a12a0)

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.

| Readout | Meaning |
| --- | --- |
| FPS + rolling chart | Wall-clock buckets; a bucket with no frames records 0 and draws as a neutral baseline, never a stale value |
| Timing | CPU wall time of the target’s last frame: layout, render-command generation and so on |
| Summary / Interaction | Hit registry, mouse-hit, redraw streak, focus and interaction rebuilds |
| Cache / Rebuild | Retained-cache hits / misses, full / partial rebuilds |

## Test entry points

```sh
zig build test-headless
zig build test-ui
zig build test-render
zig build hello-button
```

> WARNING
> 
> **Don’t run zig test on internal files.** Zenit modules import across directories, so use the test steps defined in `build.zig`; otherwise you may hit `import of file outside module path`. The full list is in [Commands](https://ui.surf/zenit/docs/reference/commands); for real-window tests see [E2E harness](https://ui.surf/zenit/docs/advanced/e2e).

## 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.
