Quality

DevTools.

aktion-runtime/devtools is an in‑page debugger for every <aktion-app> on the document. You mount it yourself with one line — there is no browser extension to install — and you get fourteen tabs over the running program: a component inspector with an element picker, a live editable $state tree with time travel, a render profiler, an effect timeline, a network inspector that can mock requests, a console with an expression REPL, route / data / theme / source explorers, and a test toolkit — all of it reachable from a command palette on Ctrl / Cmd + K.

What it is

DevTools is a second, opt‑in entry point of the same package. It renders a panel inside its own shadow root — floating or docked to any edge — so it cannot be restyled by the app it inspects and can still debug a program that is throwing.

TabAnswersThe number on the tab
Overview Is anything broken, what is expensive, and where should I look?
Inspect What component rendered this element, what props / state does it hold, and what does it look like in the box model? Active prop overrides
State What is $state right now, what just changed, which atom churns most — and what did it hold two commits ago? Reactive atoms in the snapshot
Profiler What re‑rendered on this commit, why, how long did each body take, and how much went to the DOM diff? Commits captured
Effects Which effect ran, what fired it, how long it took, did it throw — and what is mounted but never firing? Effect events (or errors, if any)
Network What did the app request, what came back — and what happens if that endpoint is slow, broken, or offline? Requests (or failures, if any)
Console What did the program log, what is the runtime warning about, and what does this expression evaluate to right now? Errors
Routes Where am I, which arm matched, what routes exist, and how did I get here? Navigations
Data What is in the query cache, what do the stores and forms hold, and what is in browser storage? Queries in flight
Theme Which tokens are in effect, what happens if I change one, and does this pair pass contrast? Token overrides
Source What is the program, where are its problems, what does it declare — and does my edit parse? Errors
Test Can I turn this bug into a test, is this accessible, what is covered, and what breaks under random clicking? Recorded steps or audit findings
Timeline What happened, in order, across every subsystem?
Settings How much instrumentation do I want to pay for, and where should the panel sit?

Everything you see is the real runtime. State edits go through the genuine reactive pipeline, prop overrides enter the component where the value normally would, the profiler reads the actual per‑instance memoization decisions, the timeline is fed by the real effect runner, and the network tab taps the real HTTP layer. Nothing is simulated.

Enable it

The runtime never installs the panel. You do, from anywhere in your own code, and the whole surface is one function call.

From a bundler

import "aktion-runtime";                              // registers <aktion-app>
import { mountDevtools } from "aktion-runtime/devtools";

mountDevtools();   // opens immediately, attaches to every app on the page

That is the whole setup. mountDevtools() installs the global hook, appends the panel to document.body, and adopts every <aktion-app> already in the document — including apps that mounted long before the panel opened.

From a CDN

<script type="module">
  import "https://cdn.jsdelivr.net/npm/aktion-runtime/dist/aktion.js";
  import { mountDevtools } from "https://cdn.jsdelivr.net/npm/aktion-runtime/dist/devtools.js";
  mountDevtools();
</script>

Both bundles are self‑contained ES modules, so no build step is involved. The DevTools bundle talks to the runtime only through the global hook, which is why loading them from two different URLs still works.

Behind a flag or a keyboard shortcut

Because it is a separate entry, a dynamic import() keeps the panel out of your main chunk entirely until someone asks for it.

let devtools = null;

async function toggleDevtools() {
  if (!devtools) {
    const { mountDevtools } = await import("aktion-runtime/devtools");
    devtools = mountDevtools();     // first press mounts it, already open
  } else {
    devtools.toggle();              // later presses just show / hide
  }
}

// Ctrl+Shift+D, or ?debug in the URL.
addEventListener("keydown", (e) => {
  if (e.ctrlKey && e.shiftKey && e.key === "D") toggleDevtools();
});
if (new URLSearchParams(location.search).has("debug")) toggleDevtools();

Aktion ships no keyboard shortcut of its own, so the binding above is yours to choose. Mount once and keep the controller: every call to mountDevtools() adds another panel element, even though they all share one hook and one event stream.

Client only

mountDevtools() needs a live document. Under SSR or a framework that renders on the server, call it from a client‑only effect (useEffect, onMounted, a <script> in the browser bundle), never at module scope in shared code.

Try it live

The app below is an ordinary <aktion-app> rendered from the program on the left. Press Launch DevTools to open the real panel over this page, then interact with the app and watch it fill up. Press Ctrl / Cmd + K inside it for the command palette, or ? for the shortcuts.

Drag the panel by its header, resize it from the corner grip, press again to toggle.
Live — the program the panel inspects
$count = 0
$seconds = 0
$user = { name: "Ada", role: "engineer", prefs: { notify: true } }

// An interval effect: one `run` event per second, so the Effect timeline fills live.
$effect(() => { $seconds = $seconds + 1 }, ["every(1000)"])

// A user component: its own instance, and its own memo record, in the profiler.
function Stat(label, value, tone) {
  return Card([Column([
    Text(label, { variant: "small", tone: "muted" }),
    Text(value, { variant: "title" }),
    Badge(tone, { tone: tone })
  ], { gap: "xs" })])
}

$app(Column([
  CardHeader("DevTools demo", { subtitle: "Open the inspector, then interact." }),
  Row([Stat("Count", `${$count}`, "info"), Stat("Uptime", `${$seconds}s`, "success")], { grow: true }),
  Button("Increment", { onClick: () => $count = $count + 1, variant: "primary" }),
  Text(`Signed in as ${$user.name} (${$user.role})`, { variant: "small", tone: "muted" })
], { gap: "md" }))

Three things to look at, in order:

The command palette, and the keyboard

Fourteen tabs is more surface than anyone wants to hunt through. Press Ctrl / Cmd + K anywhere on the page — the panel does not need focus — and type: the palette fuzzy‑matches every tab and every action the panel can perform, so pick an element, arm a break, reset the theme, or export the session are one phrase away instead of three clicks inside a tab you have not opened.

Press ? for the full list of shortcuts at any time:

KeyDoes
Ctrl / Cmd + KOpen the command palette.
Ctrl + Shift + PArm the element picker.
?Show / hide this list.
Alt + 19Jump straight to a tab.
Alt + [ / ]Previous / next tab.
/Focus the current tab's filter box.
Ctrl + FFind — in the Source tab, search the program.
EscapeClose the palette, the shortcut sheet, or disarm the element picker.

The palette, the picker, and the Alt tab shortcuts work anywhere on the page — you are usually clicking the app, not the panel, when you want to change what the panel is showing — and they never fire while you are typing in the app's own fields. The rest need the panel focused; click it once.

Typing is never interrupted

The panel re‑renders on every runtime event, which for a busy app is several times a second. Every field declares a stable key, so your focus, your caret position, and the scroll offset of whatever list you are reading survive those re‑renders — including the Enter that runs a REPL expression and grows the history above the input.

Watching an app while you use it

Four things the panel can do continuously, so you can put it in a corner and go use the app rather than clicking back and forth. All four are switches in Settings → While you work, and each is off by default because each costs something.

Alongside them, the panel reports main‑thread blocking it observed (via PerformanceObserver's long‑task entries) on the Overview tab, so a janky interaction points at the frame that caused it.

Paused means paused

The Rec button in the header stops the panel ingesting events, which is how you read a moving timeline. Those events are dropped, not queued, so the button counts what pausing has cost you — Paused · 34 — rather than looking like a hung panel.

Component inspector

The Inspect tab is the one that changes how you debug an Aktion app. It shows the component‑instance tree the renderer actually built — not the DOM, and not the source — and lets you change what any instance received and what it holds.

The element picker

Press ◎ Pick and the page gets a crosshair. Hover anything and the real box model is drawn over it — margin, border, padding, content, each labelled with its measured value — with a tooltip naming the component, the element, and its size. Click to select; Esc cancels.

The picker reaches inside the app's shadow root. A naive implementation cannot: document.elementFromPoint stops at the <aktion-app> host, so every hover would resolve to the same element. This one descends through each shadow root until it reaches a leaf, then walks back up to the nearest component instance — so clicking the text inside a Badge selects the Badge, not an anonymous <span>. Hovering the panel itself is ignored, and clicking it does not cancel the pick you are halfway through.

Reading the component tree

Every row is one instance: the component name (your function components in purple, library primitives in grey), the author's key: if there is one, a memo chip when the last commit skipped its body, its prop count, how many times it has rendered this session, and its last self time. Hovering highlights it on the page; clicking selects it. A row marked no dom rendered a fragment with no host element (a Show / Async / Lazy branch), so there is nothing to highlight.

The tree comes from the instance keys the renderer derives, which encode the whole hierarchy — $/0#Page@1:0/1#Card@7:4>0#Button@9:12 is a Button inside a Card inside a Page. Filtering flattens the tree into a result list, because keeping the hierarchy would mean showing unmatched ancestors as though they matched.

Given room — a panel wider than about 700 pixels, so any side dock or a floating panel at its default size — the tab splits: the tree on the left, the selected instance's detail on the right, both scrolling independently. Narrower than that, the detail replaces the tree, because two 300‑pixel columns help nobody. / walk the rows, / collapse and expand, and the / buttons in the toolbar collapse or expand everything at once.

Every other tab that names a component links here: a bar in the flamegraph, an effect's owner, a timeline row. Following one of those links clears whatever was hiding the row — a collapsed ancestor, a filter that excludes it, the Library toggle when the target is a library component — tells you what it cleared, and scrolls the row into view, so the jump never lands on an empty‑looking tree.

Editing props, hooks, and component state

Selecting an instance opens six panes. Everything editable is edited in place: click the value, type, Enter to commit, Esc to cancel. Input is parsed as JSON when it can be (42, true, ["a"]) and as a plain string otherwise, so Ada is not rejected for being invalid JSON.

PaneWhat it showsWhat an edit does
Props Every argument the instance received, by its declared name, plus anything on the universal channel (sx, class, aria…). A $‑bound prop writes the atom — that is the source of truth, and overriding the prop instead would be reverted on the next commit. Anything else installs a DevTools override that lasts until you clear it.
State Per‑instance $state / $memo / $ref cells by slot index, plus the internal UI state a library component keeps for itself. Writes the cell and re‑renders, exactly as the hook's own setter would. A $memo is read‑only: it recomputes from its deps, so an edit would not stick.
DOM The element, its box model, its attributes, and its markup — with copy‑selector, copy‑HTML, and a button that console.logs the live node so you can poke at it. Read‑only. An imperative DOM write has no source of truth the next render can reproduce, and the reconciler would undo it.
Styles Computed properties grouped by concern, and every --rui-* theme variable actually in effect on this element, with swatches. Read‑only here — change the token in the Theme tab and the whole library follows.
A11y The role and accessible name a screen reader would announce, the ARIA attributes that decide them, and any audit findings in this subtree. Read‑only; each finding highlights its element and names the fix.
Source The lines around the call site, with the instance's own line highlighted. Read‑only; “Open in Source tab” jumps to it in the full program.

The row at the bottom of the Props pane adds an override for a prop the call site never passed — which is usually the interesting experiment. Trying variant: "danger" or sx: { padding: 24 } on one live component takes a second and leaves no edit behind in the program.

An override is a lie you asked for. While one is active the UI is showing a DevTools value, not the program's — so the tab shows a banner with a “clear all” button, each overridden prop carries an override chip, and clearing restores the authored value on the next commit. Overrides are per‑instance and are dropped when the program is re‑planned.

Remount drops the instance's memo, hook cells, and UI‑state slots so it mounts fresh — the fastest way to check that a component's initial state is what you think it is, without reloading the page.

State inspector

The State tab is a live tree of every reactive $state atom, updated on every state flush — including flushes the render gate decides not to re‑render for. So the tree mirrors the store even when the screen does not move.

Reading the tree

One row per atom, indented by depth. Values are coloured by type and collapsed to a preview until you expand them.

You seeIt means
/ An object or array with at least one entry. Click to expand or collapse.
A leaf — a scalar, an empty object or array, or a function.
Array(12)Array preview, with its length.
{ name, role, prefs }Object preview: the first three keys, then if there are more.
ƒ ()A function-valued atom. Shown, never editable.
reserved tagAn atom the runtime owns. route is the only one; its value is not editable.
Row highlightedThe atom’s root changed in the last 1.1 seconds. The flash is keyed on the root, so every row under user flashes when user.prefs.notify changes.

Only $‑prefixed reactive atoms appear here. Plain top‑level let / const bindings are re‑seeded every render and are not part of the store, so they are never listed — see Reactivity for the distinction.

Editing a value

Click any leaf value to turn it into an input. Enter commits, Escape cancels, and clicking away also commits. What you type is run through JSON.parse first and kept as raw text only if that fails.

You typeWhat is written
42The number 42
falseThe boolean false
nullnull
{ "a": 1 }An object — any JSON literal works
"true"The string true
pendingThe string pending — not valid JSON, so it is kept verbatim

A dotted row such as user.prefs.notify writes through the store’s path API, which rebuilds user immutably: sibling fields survive and every dependent of user wakes up. The edit is indistinguishable from one an onClick handler would make.

Containers are not editable

Edit leaves, not branches. An object or array that has entries is a container — expand it and edit the values inside. An empty object or array is a leaf, so you can replace it wholesale by typing a JSON literal over it.

Filtering and reactivity heat

Every top‑level atom carries a heat badge: a bar sized against the busiest atom and a count of how many flushes changed it this session. The toolbar shows the session total.

Sort by activity is the fastest way to find the atom your UI actually churns on. An atom with a heat count far above the rest is usually a timer, a scroll position, or a pointer coordinate that belongs in an instance hook rather than the global store.

Time travel

The runtime attaches a $state snapshot to every commit, so the State tab keeps a bounded history of moments you can recognise — “two clicks ago” — rather than raw flushes. Drag the scrubber back and the tree shows that snapshot, read‑only, with a banner saying how long ago it was. Restore hydrates it back into the live store; Live follows the app again.

Restoring is a real hydration, not a rewind: effects do not un‑run and requests do not un‑send. It puts the data back so you can re‑drive the UI from a state you have already seen.

Diffing two snapshots

Reading two state trees side by side and spotting the difference is a job for a computer. The State tab's Diff view takes any two of the captured commits and lists only what changed between them — added, removed, or changed, at the leaf, with both values — which is usually three lines instead of two hundred. Coming from a bug report ("it was fine, then I clicked twice"), this is the shortest path from "something changed" to "this changed".

It needs captureSnapshots on (it is, by default) because it reads the same per‑commit snapshots time travel uses.

Render profiler

Every commit is captured with its trigger, its wall‑clock duration, and one record per component instance. Open the Profiler tab, interact with the app, and read it top‑to‑bottom: summary, strip, insights, commit detail, reactivity, ranked components.

The commit strip

One bar per commit, oldest on the left. Bar height is that commit’s duration relative to the slowest one captured, so the strip is a shape, not an axis. Click a bar to pin it; the Commit detail section below follows your selection instead of the latest commit.

BarMeansSource flag
Green The initial mount — the first commit DevTools captured for this app. There is exactly one. initial: true
Amber A full render: memoization was bypassed and every component body re‑ran. fullRender: true
Blue An incremental commit — the normal case, where memoized instances were skipped. neither flag

Green wins over amber, so the first captured commit reads green even though it is also a full render. Hover any bar for #7 · 3.10 ms · 4 rendered / 9 memoized, and look for runs of amber: repeated full renders are the single most common Aktion performance bug.

The flamegraph

Commit detail lists every instance in the selected commit, in render order, indented by tree depth. Bar width is that instance’s self time relative to the widest one in the same commit. Library components are prefixed with a small square; user components are not.

PhaseBarWhat happened
mount Green This instance appeared in the tree for the first time.
update Blue The instance already existed and its body re‑ran.
memo Grey, dimmed Skipped by per‑instance memoization — the body did not run and the cached tree was reused. Fixed‑width bar; the time column reads memo.

Hover a bar for the reason the phase was chosen. These strings come straight from the renderer, so they name the exact decision it made:

ReasonRead it as
initial mountFirst render of this instance; nothing was cached yet.
positional args changedA positional argument differs from the memoized call.
named args changedA prop in the options object differs.
state dependency changedArgs matched, but a $state path this body reads was in the commit’s change set.
no memo (full render) / full renderMemoization was off for this commit, so the body re‑ran regardless.
memoized (args + deps unchanged)Both checks passed; the body was skipped.
mounted / re-renderedA library component. These two are the only reasons library instances report.
render threwThe component’s render function raised. Check the console for the error.

Self time means two different things

Compare user components with user components. For a function Foo() the number is body‑evaluation time only, with children excluded. For a library component it is inclusive of the subtree its render function builds in one call. A memo record always reports zero.

Performance summary and the components table

The stat grid at the top of the tab aggregates every captured commit. Several tiles change colour once they cross a fixed threshold, so you can read the grid without knowing what “good” looks like.

TileWhat it measuresTurns amber at
commitsCommits captured this session.
total renderSum of every captured commit’s duration.
avg / commitTotal divided by commit count.≥ 8 ms
slowestThe single longest commit. Click the tile to pin that commit in the strip.≥ 16 ms
memoizedShare of component evaluations that were skipped.below 20% — green above it
full rendersCommits that bypassed memoization entirely.more than half the commits
commit rateCommits per second across the captured span. Hidden until the commits span a measurable interval.≥ 30/s

Below the flamegraph, Components — ranked by self time aggregates the same records by component name across every captured commit. Click any header to sort by it; clicking the active header flips the direction. It opens sorted by Total, descending.

ColumnMeaning
ComponentComponent name. Instances of the same component are merged into one row.
Typeuser for a function in your program, library for a built‑in.
RendersCommits in which a body actually ran. Memoized skips are excluded.
MemoCommits in which the instance was skipped.
TotalSummed self time, with an inline bar against the heaviest row.
AvgTotal ÷ Renders.
MaxThe worst single render. A high Max with a low Avg is a first‑render or cold‑cache cost.

A healthy row has Memo comfortably above Renders. A user component with a large Renders and Memo of zero reads a $state path that changes on nearly every commit.

Insights and the reactivity panel

Insights is a fixed set of heuristics over the captured commits, capped at six entries. Each one names the component and the number that triggered it, so you can verify the claim in the table below.

Reactivity — state paths that triggered commits ranks the top eight $state paths by how many commits they caused. It answers a different question from the heat badges on the State tab: heat counts every write, this panel counts only the writes that actually cost you a render.

Flash on commit outlines the inspected element for 140 ms on every commit — the quickest way to tell which of several apps on a page is the one re‑rendering. Clear empties the strip without stopping recording.

Effect timeline

Every transition of every effect, attributed to the trigger that caused it. This is where you take an effect that “runs too often” and find out what is firing it.

Phases and triggers

Five phases, each with its own colour and its own extra payload. They arrive in lifecycle order, and a re‑run emits cleanup before its run whenever the previous run registered any cleanup handlers.

PhaseChipEmitted whenAlso carries
mount Green The declaration was registered and its subscriptions or intervals were wired — before any body runs.
run Blue The body executed. Body duration
cleanup Purple Registered cleanup(fn) handlers fired, before a re‑run or on teardown. How many handlers ran
unmount Grey The declaration was torn down — the owning instance left the tree, or the program was re‑planned.
error Red The body threw. The effect stays mounted and will run again on its next trigger. The message, and the duration up to the throw

The reason column is the attribution — the answer to “what fired this?”

ReasonFired by
mountRegistration, or a body run triggered by mounting.
state:countThe $count trigger. It shows the atom you wrote, not the per‑instance alias the runtime subscribed to.
every(1000)An interval tick, with the declared period.
unmountTeardown — the final cleanup, and the body of an on:unmount effect.

Alongside the reason, each effect shows its declared trigger list exactly as you wrote it — [$count, "mount"], [every(1000)], [debounce(250), $query]. An effect that declares no triggers at all reads [mount], which is the rule the runtime applies to it.

Timeline versus Log

The Timeline view (the default) gives each effect a lane and places one dot per event on a shared time axis measured from the first event this app emitted. Bursts, overlapping runs, and cleanup→run pairing are obvious at a glance; exact ordering within a burst is not.

Press the toggle for the Log view: a flat, newest‑first list of the last 250 matching events with a relative timestamp, the phase chip, the effect label, and the reason. Use Timeline to find the effect, Log to read the sequence.

The five phase chips in the toolbar filter both views — all on by default. Turn off run and mount and a noisy timeline collapses to just the cleanups and errors you care about.

Lanes, summary, and insights

Between the insights and the visualisation, one lane per effect lists its label, its declared triggers, its run count, and its total run time, sorted by run count. Effects declared inside a function body carry an instance tag, and report their unmount when that component leaves the tree.

Effect labels are always derived from source position — effect @ L3:C1 is the effect declared at line 3, column 1. There is no way to name an effect, so the position is the identity: move the declaration and its lane is a new lane.

Summary tileWhat it counts
effectsDistinct effects seen, counting each component instance separately.
runsrun events.
total run / avg runSummed and mean body duration across those runs.
cleanupscleanup events, not individual handlers.
errorserror events. Green at zero, red above it.

The Insights list applies three rules per effect, capped at six entries: any error at all is flagged red; ≥ 20 runs is flagged as a hot trigger worth re‑checking the dependency list for; otherwise an average of ≥ 6 ms per run is flagged as heavy work in an effect body.

Network, and request rules

Every request the Aktion HTTP layer issues — $query, $mutation, Http({...}), and $http — is listed with its status, method, path, size, duration, and a waterfall bar. The detail pane has the response body, the request body, both header sets, and a timing breakdown, plus Copy as curl for reproducing it outside the app.

Rules is the half that makes this a testing tool. A rule matches by URL (substring, or a glob with *) and optionally by method, and then does one of four things:

ActionEffectUse it for
delayAdds latency, then proceeds normally.Seeing your own loading states. A skeleton nobody has ever watched is usually wrong.
mockAnswers with a canned status + body; the network is never touched.An endpoint that does not exist yet, an empty list, a 500, a paginated response you cannot easily produce.
failFails the request with your message.Error paths, retry logic, onDone handlers.
offlineFails everything.The offline banner, and what an app does when nothing resolves.

Rules are evaluated in order and the first enabled match wins; each rule shows how many recorded requests it would have claimed, so a rule that matches nothing is visibly different from a rule that is not working. A mocked or blocked row is labelled with the rule that produced it, so a mocked 500 never reads as a real one. Mock this on any recorded request seeds a rule that replays its response.

The program sees exactly what it would see from the network: a mocked response arrives through the same code path as a real one, and a failure surfaces as a transport error. Rules live on the inspected element and are dropped when it unmounts.

Console and the expression REPL

The panel mirrors the page console, which matters more than it sounds: the runtime's own diagnostics land there — “a reactive $state write happened during render…”, “failed to render Button”, “handler rejected” — and they are usually the most direct explanation of a reactivity bug. Runtime lines carry a runtime chip, consecutive duplicates collapse into a count, and uncaught errors and unhandled promise rejections are captured too.

Below it, a REPL that evaluates Aktion expressions — not JavaScript — against the live program scope:

› $user.name
‹ "Ada"

› $todos.filter(t => !t.done).length
‹ 3

› $count = 5          // writes through the real reactive pipeline
‹ 5

The same bindings, the same atoms, the same helpers the program sees. An assignment to a $ atom is indistinguishable from one a button made. Reads are deliberately not tracked as render dependencies, so poking at state from the console cannot change what the app re‑renders on. / walk the history.

Routes and data

Routes lists the patterns the program declares, read statically from its $router({ … }) arms rather than from observation — a router only discovers a pattern when it matches, so a history‑based view could only ever show you where you have already been. Every concrete route is clickable, and the navigation history records each change with the arm that claimed it. A navigation that matched nothing is called out: without a default: arm the router renders nothing at all for that path.

Data covers the three places state hides from the State tab:

Theme tokens

Aktion's components take almost no styling props: they read --rui-* custom properties, and a theme is a map of those. That makes the token editor the highest leverage surface in the panel — one edit restyles every Button, Card, and Table at once.

Tokens are grouped the way a designer thinks about them (surfaces, text, brand, status, typography, spacing, elevation), colour‑valued ones get a picker, and edits are applied as inline custom properties exactly the way an in‑script $theme({...}) block applies them. Copy as $theme hands you the block that reproduces what you just did. A token the program's own $theme({...}) sets is marked, because that block is re‑applied every render and wins.

Below the tokens, contrast checks for the pairs the stylesheet actually paints — body text, muted text, text on a surface, primary buttons, accent fills, links, control borders — each with its measured ratio against the WCAG minimum. “Check your contrast” is useless without knowing which pairs matter.

Source

Aktion programs are usually generated, which changes what a source view is for: you are rarely reading code you wrote, you are checking what the model emitted and where the validator disagreed. So this tab leads with diagnostics placed on their lines and an outline of every declaration — components, effects, actions, hooks, atoms, imports — that you can jump through.

Edit turns it into an editor. The draft is parsed and schema‑checked as you type, so Apply is never a surprise, and mounting goes through the same path a streamed update takes: $state is preserved across the diff. The analysis runs the runtime's own parser and validator — the panel deliberately does not carry a second one, because an inspector that disagreed with the runtime about whether a program is valid would be worse than no inspector.

Because an edit can break the program badly enough that there is nothing left to edit, the tab keeps a history: every version that has been mounted this session, with the diagnostics it produced and a one‑click Revert. A bad apply is a two‑second undo rather than a reload that loses your $state.

Ctrl + F searches the program and marks every hit; Ctrl + Enter applies a draft. Large programs stay cheap: the view renders a window of lines around what you are looking at rather than rebuilding every line on every runtime event.

Test toolkit

Five tools that turn “it broke” into something you can commit.

ToolWhat it does
Record Captures your clicks, typing, selects, Enter/Esc, and navigations, then emits a runnable aktion-runtime/test file with the program inlined and the atoms that changed asserted. Typing is coalesced into one step per field. Queries follow Testing Library priority — test id, then role + accessible name, then label, then placeholder, then text — and a step that had to fall back to a CSS selector is flagged brittle in the list and commented in the output.
A11y Audits the rendered tree for the failures a generated UI actually produces: an icon button with no accessible name, a field labelled only by its placeholder, a heading ladder with a hole in it, text below the contrast minimum, focusable content inside aria-hidden, a positive tabindex, duplicate ids, dangling aria-labelledby references, nested interactive elements, targets under 24 px. Every finding names the element, states the measurement, and says what to change in Aktion terms — and clicking it highlights the element and selects its component.
Coverage Real DSL coverage. A .aktion file compiles to a JSON.parse of its AST, so V8 and Istanbul see one executed line however much DSL sits behind it — coverage has to come from the interpreter. Start it (the program re‑plans so its static shape is registered), use the app, and read lines / functions / branches per file, with the never‑executed lines clickable through to the Source tab. Exports LCOV.
Queries A Testing Library query playground. Type a role, a label, a test id, or a selector and see what matches, highlighted on the page. The useful outcome is usually discovering that a query matches three things — which is the failure you would otherwise meet in CI — and the copy button hands you the exact getBy* / getAllBy* call.
Chaos Clicks random controls a few hundred times, with a render between each, and reports every runtime and console error that appeared. Crude, and reliably effective on a generated UI full of paths nobody has clicked. Controls whose accessible name reads destructive (delete, clear, sign out) are skipped, and time travel gets you back if a run leaves the app somewhere odd.

Timeline

Every commit, effect event, request, navigation, emitted event, log line, and error in one ordered stream, with idle gaps marked. The per‑subsystem tabs each answer their own question well and none of them answers the most common one: what happened when I clicked that? A real interaction is a commit, two effect runs, a request, and a route change inside forty milliseconds, spread across four tabs.

Rows are clickable — a commit opens in the Profiler, an effect in Effects, a request in Network. Export session writes the whole capture (events, state, program, totals) to a JSON file, which is what you attach to a bug report.

Settings, and what you are paying for

Instrumentation is not free, and a debugger that silently changes the timings it reports is a bad debugger. Five switches gate the work inside the runtime, so turning one off makes the app faster, not just the panel:

SwitchPays forTurn it off when
capturePropsSerialising every instance's arguments, every commit. Required by the Props pane.You are measuring render time on a large tree.
tagDomOne attribute per rendered element. Required by the element picker and highlighting.You need the exact production DOM.
captureSnapshotsA $state clone per commit. Required by time travel.Your state is large and changes constantly.
captureNetworkRequest events, and request rules.Never, really — it is one clock read per request.
measureDomA node count after each commit.The tree is enormous.

A second group, While you work, holds the continuous aids described under Watching an app while you use it — highlight re‑renders, flash on commit, browser performance marks, console capture. Those cost the panel, not the runtime, and are off by default.

The same tab holds the panel's own preferences — dock to any edge or float, a light theme for a light host page, compact rows for a narrow dock, console capture on or off — and they are remembered between sessions.

API reference

aktion-runtime/devtools is a self‑contained ES module. Importing it registers the <aktion-devtools> custom element as a side effect, so you can also drop the tag straight into your HTML instead of calling the mount function.

ExportPurpose
mountDevtools(options?)Install the hook and mount a panel. Returns a controller.
isDevtoolsInstalled()true when a hook exists on this page, whatever installed it.
isDevtoolsActive()true only when a hook exists and a frontend is subscribed. This is the flag the runtime gates instrumentation on.
installDevtoolsHook(version?)Install the hub without any UI. For an extension or a custom frontend.
getDevtoolsHook()The installed hook, or undefined.
defineDevtoolsElement()Register <aktion-devtools>. Idempotent, and already called on import.
AktionDevtoolsElementThe panel class. AktionDevtoolsElement.tagName is "aktion-devtools".
HOOK_KEY"__AKTION_DEVTOOLS_HOOK__" — the property the hook lives under on globalThis.
DEVTOOLS_PROTOCOL_VERSIONCurrently 2. Bumped only when the event shapes change incompatibly; everything protocol 2 added is optional, so a v1 frontend still works against a v2 runtime.
buildPalette(ctx, actions)The command list the palette shows, and rankCommands / fuzzyScore behind it. Exported so a custom frontend can reuse the same commands.
exportSessionJson(ctx)The whole session — events, commits, diagnostics, program history, long tasks — as one JSON document to attach to a bug report.
diffSnapshots(from, to)Leaf‑level changes between two $state snapshots. Pure; useful in a test.

The event shapes themselves are exported as types — DevtoolsEvent, CommitRecord, ComponentRenderRecord, StateEvent, EffectEvent, RenderPhase, EffectPhase — so a custom frontend can be fully typed. See TypeScript.

mountDevtools(options?)

One call does three things: it installs the hook, registers and appends the panel element, and adopts every app on the page. It is idempotent at the hook level but not at the panel level — a second call mounts a second panel.

const devtools = mountDevtools({
  container: document.querySelector("#debug-dock"),  // default: document.body
  appId: "aktion-app-2",                             // default: the first app that registered
  tab: "inspect",                                    // default: last used, else "overview"
  dock: "bottom",                                    // "float" | "right" | "bottom" | "left"
  open: false                                        // default: true — mounts already open
});

Nothing here is required. mountDevtools() with no arguments is the call you want almost always — reach for the options only when the panel has to live somewhere other than document.body, open on a particular tab, or start hidden.

OptionTypeDefaultEffect
containerHTMLElementdocument.bodyWhere the panel element is appended.
appIdstringFirst registered appPre‑selects an app in the picker.
tabTabIdLast used, else "overview"Opens on a specific tab — "inspect", "state", "profiler", "effects", "network", "console", "routes", "data", "theme", "source", "test", "timeline", "settings".
dockDockModeLast used, else "float"Anchors the panel to an edge instead of floating.
openbooleantruefalse mounts the panel hidden. It still subscribes — see what it costs.

Floating, the panel is position: fixed at a very high z‑index, sized 560×620 and placed 16 px from the bottom‑right corner. Drag the header to move it, use the corner grip to resize down to 360×260, and the header buttons to switch dock, pause recording, collapse, or close. Docked, it spans an edge and the drag/resize handles retire. Position, size, dock, theme, density, and the last tab are remembered in localStorage.

The controller

mountDevtools() returns a plain object. Keep it: it is the only handle on that panel.

MemberTypeDoes
elementAktionDevtoolsElementThe live panel element.
hookAktionDevtoolsHookThe installed hook — shared by every panel on the page.
open()voidShow the panel and resume rendering it.
close()voidHide it. It stays subscribed and keeps recording.
toggle()voidFlip hidden state. Bind this to your shortcut.
selectApp(id)voidSwitch the inspected app. Also clears the pinned commit, instance, and request.
selectTab(tab)voidSwitch tabs — e.g. open straight on "network" from your own “report a bug” button.
destroy()voidRemove the panel element. It unsubscribes, so the runtime goes dormant again — but the hook and its buffered events stay installed.

close() and destroy() are not interchangeable. close() is a UI state; destroy() is the one that turns the instrumentation back off.

The hook and the app record

The hook is the wire between the runtime and any frontend. You rarely touch it directly, but reading it in the console is a fast way to confirm DevTools is wired up at all.

const hook = window.__AKTION_DEVTOOLS_HOOK__;

hook.aktion;            // true — the marker that says you found the real hub
hook.protocolVersion;   // 2
hook.apps.size;         // how many <aktion-app> elements registered
hook.active;            // true only while a frontend is subscribed
hook.buffer.length;     // recent events, capped at hook.bufferLimit (default 2000)
hook.options;           // the instrumentation switches the runtime reads

hook.setOptions({ captureProps: false });   // stop serialising per-instance props
hook.clearBuffer();                         // drop the backfill buffer

const stop = hook.subscribe((event) => console.log(event.kind, event));
// ... later
stop();

subscribe(fn) and subscribeApps(fn) both return an unsubscribe function. A listener that throws is caught and logged, so a broken frontend can never break the app it inspects.

Events come in eight kinds. commit, state, and effect are the original three; protocol 2 adds network (one start plus one terminal event per request), route (emitted after the render that resolves which arm matched, so it can report the pattern), emit (every $emit(…), mirrored before dispatch so a host that stops propagation cannot hide it), log, and error (a failure the app survived — a plan error, a render throw, a budget abort).

Each entry in hook.apps is a deliberately narrow handle. It exposes what a debugger legitimately needs and nothing else — there is no path from it to the raw runtime internals, values are serialised before they cross, and every write goes through the same reactive pipeline an event handler uses. The first seven members are the version‑1 contract and are always present; everything after them is optional, so a frontend feature‑detects and degrades instead of throwing.

MemberGives you
idStable per‑element id, aktion-app-1, aktion-app-2, …
labelThe name in the app picker: data-devtools-label if present, else the element’s id, else the generated id.
elementThe host element, for highlighting and scroll‑into‑view.
getState()The current reactive $state snapshot.
setState(path, value)Write an atom. Dotted paths are rebuilt immutably.
getProgram()The current program source text.
forceRender()Force one non‑memoized render.
Program & diagnostics
setProgram(text)Hot‑swap the running program, preserving state across the diff.
getSources()Per‑module sources of a linked program.
getDiagnostics()Structured parse + schema diagnostics from the last plan (line, column, kind, severity).
analyzeProgram(text?)Parse, validate, and outline a candidate program without mounting it.
reload()Re‑plan and re‑render from the current source.
Inspector
getRenderRoot()The element the app paints into — the anchor for DOM inspection.
getComponentTree()The instance tree of the last commit, parented and depth‑ordered.
getInstance(key)One instance in full: props, hooks, UI state, deps, effects, ancestors, DOM.
instanceForNode(node)The instance that rendered a DOM node — the element picker's other half.
nodeForInstance(key)The element an instance rendered.
setInstanceHook(key, slot, value)Write one per‑instance $state / $ref cell.
setInstanceUiState(key, slot, value)Write one useInstanceState slot.
setPropOverride(key, prop, value)Force a prop on one instance.
clearPropOverride(key, prop?)Drop one override, or all of that instance's.
listPropOverrides()Every active override.
remountInstance(key)Drop the instance's memo, hooks, and UI state so it mounts fresh.
State, effects, data
getStateMeta()Per‑atom metadata: runtime‑owned, derived, declaring module, source position.
resetState(names?)Reset atoms to their declared defaults.
hydrateState(snapshot)Restore a snapshot — the write half of time travel.
evaluateExpression(src)Evaluate an Aktion expression against the live scope.
getEffects() / runEffect(key)Every mounted effect with its subscriptions; fire one on demand.
getQueries(), refetchQuery, cancelQuery, invalidateQueriesThe $query cache, and the three things you do to it.
getStores() / callStoreMethod(atom, method, args?)$store / $form handles, and their methods.
Router, theme, network, stats
getRoute() / navigate(path)Current path, pattern, params, mode, declared routes; and navigation.
getTheme(), setThemeTokens, clearThemeTokens, setThemeNameResolved tokens, live overrides, and the theme switcher.
setNetworkRules(rules) / getNetworkRules()Install and read the delay / mock / fail / offline rules.
getStats()DOM nodes, elements, instances, atoms, effects, queries, stores, program size, JS heap.

So data-devtools-label is the one authoring hook on the app side. With several apps on a page, name them — otherwise the picker reads aktion-app-1, aktion-app-2, and you will pick the wrong one.

<aktion-app data-devtools-label="Checkout form"></aktion-app>
<aktion-app data-devtools-label="Live order feed"></aktion-app>

There is also el.connectDevtools() on the element itself. The panel calls it on every aktion-app it finds when it opens, which is what makes late‑attach work; call it yourself for an app the panel cannot reach with a document query.

How it works

The architecture is the browser‑DevTools split: a backend (the runtime) and a frontend (the panel) that only ever talk through structured, JSON‑serialisable events.

Everything is bounded, so a session can run for hours without the panel’s model growing without limit. When a cap is hit the oldest entries are dropped.

CapLimit
Hook event buffer2000 events — writable via hook.bufferLimit
Commits kept per app300
Effect events kept per app600
Requests kept per app300
Console lines kept per app500 — consecutive duplicates collapse into a count
Navigations / emitted events / errors200 each
State snapshots kept for time travel60 commits
Props recorded per instance40
Elements examined by the a11y audit4000 — it says when it truncated
Rows in a flat log view250–400 most recent matches
Change‑flash duration1100 ms

What it costs when it is not open

The runtime always attempts to talk to the hook, so the cost question is really about which of three states you are in. Only the third one is not free.

StateisDevtoolsActive()What the runtime pays
You never import aktion-runtime/devtools false One property read per emit site. No profiler records, no event objects, no panel code in the bundle.
Hook installed, but nothing subscribed — including after destroy() false The same single property read. Instrumentation stays dormant.
A panel is mounted — even hidden by close() or open: false true Per‑commit timing plus one record per component instance, a state snapshot on every flush, and whatever the instrumentation switches are set to.

The gate is decided once per commit, so the profiler flag stays consistent across a whole render pass. A hidden panel skips its own rendering, but it is still subscribed — which is why destroy(), not close(), is what you call before measuring real performance numbers.

Within the third state, the Settings tab decides how much you pay: prop capture serialises every instance's arguments per commit, DOM tagging writes an attribute per rendered element, and snapshots clone the store per commit. Turning one off makes the app faster, not just the panel — which is what you want when the thing you are measuring is render time itself.

Do not ship it enabled

Gate the mount, not just the panel. Put mountDevtools() behind a dynamic import() that only a flag, a shortcut, or a non‑production build can reach. The panel can read and write any atom in the program, so an always‑mounted DevTools in production is a debugging surface you did not intend to publish — see Security.

Troubleshooting

A handful of failures account for almost every “DevTools isn’t working” report. Work down the list in order — each one rules out the ones below it.

No panel appears

The hook is not installed

Type window.__AKTION_DEVTOOLS_HOOK__ in the console. If it is undefined, no frontend has run — the runtime never installs the hook by itself.

“No Aktion app detected”

The panel adopts apps two ways: they register themselves when they render, and on open the panel calls connectDevtools() on every aktion-app it can find with a document query. If the picker is still empty:

The Profiler tab is empty

The State tab is empty, or an edit does nothing

When the DOM reverts instead of the state

Add strict to the app element. If an attribute a handler wrote onto the live DOM keeps disappearing, that is the reconciler, not DevTools: <aktion-app strict> arms a guard that logs exactly which attribute each commit reverted and on which element. See Troubleshooting.

The Inspect tab is empty, or the picker selects nothing

A prop edit does not stick

The Network tab shows nothing

Next