Quality

DevTools.

aktion-runtime/devtools is an in‑page debugger and QA workbench for every <aktion-app> on the document. Mount it with one line — there is no browser extension — and you get sixteen sections over the running program: an element picker and component inspector you can edit live, an editable $state tree with time travel, the query cache and browser storage, a unified timeline, a network inspector that mocks and throttles, a console with an expression REPL, a flame‑chart profiler with Core Web Vitals, a WCAG 2.2 accessibility audit, a security scanner, a test recorder that exports Aktion and Playwright tests, a live source editor, and a theme token editor.

What it is

DevTools is a second, opt‑in entry point of the same package. It renders inside its own shadow root — floating, or docked to an edge with the page pushed aside — so the app it inspects cannot restyle it, and it keeps working while that app is throwing. Everything it shows is the real runtime: edits go through the genuine reactive pipeline, the profiler reads the actual memoisation decisions, and the network view taps the real HTTP layer.

The sections, in the order the sidebar shows them:

Overview Home

Health, performance at a glance, render activity, what drives re-renders, and one-click actions.

Inspect Inspect

Pick any element to find its component; edit props, hooks and internal state; box model, styles, accessibility.

State Inspect

Every atom, editable in place, with activity heat, a leaf-level diff, a change log, a reactivity graph, and time travel.

Data Inspect

The query cache (refetch, invalidate, simulate loading or failure), stores and forms, and browser storage.

Routes Inspect

The current route, every declared arm with a form for its params, a match tester, coverage, and history.

Timeline Activity

Interactions, commits, state, effects, requests, routes, events, logs, errors and long tasks on one zoomable axis.

Network Activity

Requests with a waterfall, bodies and timings; copy as cURL or fetch, export HAR; mock, delay, fail, throttle.

Console Activity

Program and runtime logs, an expression REPL with completion and history, and pinned watch expressions.

Effects Activity

Every mounted effect with its triggers and cost, a per-effect timeline, and the full lifecycle log.

Performance Performance

Commit chart, flame chart, ranked and per-component costs, why each render happened, insights, and web vitals.

Accessibility Quality

A WCAG 2.2 audit numbered on the page, the accessibility tree, a keyboard focus walk, and vision simulation.

Security Quality

What the program can reach, sanitiser escapes in the DOM, origins, secrets in storage, headers and CSP.

Testing Quality

Record and replay flows, export Aktion or Playwright tests, scenarios, coverage, a query playground, chaos, emulation.

Source App

The program with diagnostics on their lines, an outline, find, a live-validated editor, history, diff, and revert.

Theme App

Every design token, editable live, with colour pickers, contrast checks for the pairs the library paints, and export.

Settings System

Appearance, behaviour, runtime instrumentation, session export/import, shortcuts, and versions.

A section earns a badge on the sidebar only when something needs you — failing queries, unmatched navigations, serious accessibility issues, high-severity security findings, program errors, a recording in progress — so a quiet sidebar means a healthy app.

Enable it

The runtime never installs the panel. You do, from 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

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

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

Because it is a separate entry, a dynamic import() keeps the panel out of your main chunk until someone asks for it. Once mounted, the panel binds its own page‑wide shortcut, Shift + Alt + D, to show and hide itself.

let devtools = null;

async function openDevtools() {
  if (devtools) return devtools.open();
  const { mountDevtools } = await import("aktion-runtime/devtools");
  devtools = mountDevtools();   // from here on, Shift+Alt+D toggles it
}

if (new URLSearchParams(location.search).has("debug")) openDevtools();

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, 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 use the app and watch the panel fill up. Inside it, press Ctrl / Cmd + K for the command palette or ? for every shortcut.

Drag the title bar to move it; drag it to an edge to dock. Shift+Alt+D toggles.
Live — the program the panel inspects
$count = 0
$seconds = 0
$user = { name: "Ada", role: "engineer", prefs: { notify: true } }

// An interval effect: one `run` per second, so the 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" }))

Four things to try, in order:

The panel

The panel floats by default — drag its title bar anywhere, resize it from any edge or corner, double‑click the title bar to maximise. Drag it against a screen edge and it snaps into a dock; drag a docked panel away from its edge and it tears off into a window again. Docked right, left, or at the bottom, it pushes the page aside the way browser DevTools shrink the viewport, so the app stays fully visible (switch this off in Settings to overlay instead).

Typing is never interrupted

The panel re‑renders on runtime events, several times a second in a busy app. It is built on a keyed reconciler that updates the DOM in place, so the field you are typing in keeps its focus, caret and draft, lists keep their scroll offset, and charts keep their zoom while events stream in behind them.

The command palette, and the keyboard

Press Ctrl / Cmd + K inside the panel, or Shift + Alt + K anywhere on the page, and type. The palette fuzzy‑matches every section, every action a section offers (run accessibility audit, emulate offline, simulate deuteranopia, start recording a test), every component on screen (Inspect Counter), every atom, every declared route, and every panel command (dock to the right, export the session, copy a bug report).

KeyWhereDoes
Shift + Alt + DAnywhereShow or hide DevTools.
Shift + Alt + CAnywherePick an element to inspect.
Shift + Alt + KAnywhereOpen the command palette.
Alt + 1…9AnywhereJump to a section.
Alt + [ / ]AnywherePrevious / next section.
Ctrl / Cmd + KPanelCommand palette.
?PanelEvery shortcut.
/PanelFocus the current section's filter.
EscapePanelBack out of a menu, dialog, the palette, the picker, or an edit.

The page‑wide shortcuts use Shift + Alt so they cannot collide with the app's own bindings — Ctrl/Cmd + K is how half of all apps open their search — and none of them fire while you are typing in one of the app's fields.

Watching an app while you use it

Inspect

Press the crosshair and hover the page: the box model is drawn over the element with its component, size, role and accessible name, and a contrast chip for text. Click to select. While picking, Alt + wheel or ↑/↓ walks to the parent and back, so you can reach a container that has no gap of its own to click. The picker's crosshair takes the click, so picking a Delete button does not delete anything.

State

Every atom, as a tree you can edit in place: click a leaf, type, press Enter. The type follows the edit (42 into a number field writes a number; into a string field it stays the text "42"). Add keys and items, delete them, or edit a whole value as validated JSON. Derived atoms, cached queries and runtime‑owned atoms are labelled, and an activity bar shows how often each atom changes — sort by Activity to find the one churning.

Data

Routes

The current path, the arm that matched, and its params; then every route the program declares, found by a static walk of $router({…}) rather than from history, so you can visit a page before you have built a link to it. Arms with :params get a small form. Type a path to see which arm it would hit, check the session's route coverage (which declared routes were never visited), revisit any navigation from the history, or replay the whole journey.

Timeline

One axis for everything: interactions, commits, state changes, effect runs, requests, navigations, custom events, logs, errors and long tasks, each on its own track. Wheel to zoom, drag to pan, Shift + drag to select a range, and the list below follows. Select an event for its details and a link to the section that owns it.

Network

Every request the Aktion HTTP layer makes — $query, $mutation, Http({…}), $http — with status, size, duration and a waterfall. Filter by text, status class, or failures. The detail pane shows headers, payload, the response as a tree or raw, and timing, and each request can be copied as cURL or fetch. Export the whole list as a HAR file.

Console

The program's console output and the runtime's own warnings in one stream, filterable by level and by origin, with consecutive duplicates collapsed. The REPL evaluates any Aktion expression against the live scope — $count + 1, $todos.filter(t => !t.done).length, even $count = 5 — with completion for atoms (Tab) and history (↑/↓).

Effects

Every mounted effect with what it is for (on $count, every 3s, on mount), how often it ran, how long it takes, and whether it threw. Select one for its triggers, subscriptions, timers and recent lifecycle; run it on demand; switch to Timeline to see runs as spans per effect, or Log for every mount, run, cleanup and unmount.

Performance

The commit chart along the top shows every commit against a 16 ms frame budget; select one and the views below explain it.

Accessibility

Issues runs an audit over the rendered tree — names, labels, contrast, ARIA validity and wiring, keyboard reachability, target size, structure and media — and scores it out of 100 the way Lighthouse does: each failing rule costs by impact, so ten icon buttons missing a name cost less than ten different problems. Findings are grouped by rule, filterable by impact and category, and numbered; switch on Markers and the same numbers appear over the page. Select one for what is wrong, how to fix it in Aktion terms, the WCAG 2.2 success criteria it fails (linked to the W3C's Understanding documents), the element, and what a screen reader announces for it. Contrast failures come with the nearest passing colour in the same hue. Turn on Auto to re‑run after every commit.

Automated checks catch roughly a third of real barriers. The focus walk and the vision simulations are there for the rest.

Security

Aktion's trust model has two inputs with opposite privileges: the program text is trusted code, and everything it renders is untrusted data the library sanitises (see Security). The Security section follows that split instead of printing a generic checklist, and scores the page out of 100.

Every finding names its evidence and its fix, and exports as Markdown or JSON. Nothing here sends data anywhere: the header check is the only request the panel makes, and only when you press the button.

Testing

Source

The program with syntax highlighting, diagnostics marked on their lines and explained at the end of them, find‑in‑source, and a sidebar with the outline (components, state, actions, effects, bindings), the problem list, and the program's version history. Edit opens an editor that validates the draft as you type (the runtime's own parser, so it never disagrees with the app); review the draft as a diff, then Apply (Ctrl/Cmd + S) to hot‑swap it with state preserved — with an undo. Every version the app ran is kept, with a diff against the current one and one‑click revert, so an edit that broke the app is one click from undone.

Theme

Every design token the library reads, grouped the way a designer thinks about them (surfaces, text, brand, status, typography, spacing and shape, elevation and motion). Click a swatch for a colour picker, or edit any value — ↑/↓ nudge numbers in their own unit, with Shift for ×10 — and every component restyles instantly. The contrast panel checks the pairs the library actually paints (body text, muted text, buttons, links, status text, control borders, focus ring) and offers the nearest passing colour for any pair that fails. Copy your edits as a $theme({…}) block, as JSON, or as CSS variables.

Settings

Appearance (theme, density, motion, position, page push, sidebar labels, the launcher), behaviour (console capture, re‑render highlighting, commit flashes, User Timing marks, automatic accessibility audits), and the runtime instrumentation the app pays for while DevTools is open:

SwitchRecordsNeeded by
Capture propsPer-instance props and arguments in every commitInspect's props, Performance's reasons
Tag DOM nodesdata-aktion-instance on rendered elementsThe picker and every highlight
State snapshotsA $state snapshot per commitTime travel and diffs
Network eventsRequests from the HTTP layerNetwork, and every request rule
Count DOM nodesA tree walk after each commitDOM-size insights

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.

Sessions and bug reports

Export the session (Settings, or the palette) writes everything the panel captured to one JSON file: the program and its versions, the state and its history, commits, effects, requests, navigations, logs, errors, and recorded test steps. Import it on another machine and it appears in the app switcher as a read‑only app: the timeline, commits, requests, logs and state history, exactly as they happened. Copy a bug report writes Markdown with the environment, steps to reproduce, errors, failed requests and vitals, ready to paste into a ticket.

API reference

Importing aktion-runtime/devtools registers the <aktion-devtools> custom element as a side effect, so you can also drop the tag straight into your HTML.

ExportPurpose
mountDevtools(options?)Install the hook and mount a panel. Returns a controller.
isDevtoolsInstalled() / isDevtoolsActive()Whether a hook exists; whether a frontend is subscribed (the flag the runtime gates instrumentation on).
installDevtoolsHook(version?) / getDevtoolsHook()The hub without any UI, for an extension or a custom frontend.
AktionDevtoolsElement, defineDevtoolsElement()The panel element ("aktion-devtools") and its idempotent registration.
HOOK_KEY, DEVTOOLS_PROTOCOL_VERSION, DEVTOOLS_UI_VERSION"__AKTION_DEVTOOLS_HOOK__"; protocol 3; the panel's own version.
exportSessionJson, importSessionJson, bugReportMarkdownSession files and bug reports, for your own "report a problem" button.
auditAccessibility, a11yScore, tabOrder, landmarks, headingOutline, accessibilityTree, announceThe accessibility engine, usable in your own tests.
scanSecurity, checkHeaders, decodeJwt, classifySecretThe security scanner.
InteractionRecorder, generateTest, generatePlaywrightTest, resolveQuery, replayStepThe recorder, both code generators, and replay.
toCurl, toFetch, toHarRequest export.
diffSnapshots, fuzzyScore, rankCommandsLeaf-level state diffs, and the palette's ranking.

The event shapes are exported as types (DevtoolsEvent, CommitRecord, ComponentRenderRecord, StateEvent, EffectEvent, …) so a custom frontend can be fully typed. The runtime also exports its own VERSION, which is what the panel reports.

mountDevtools(options?)

const devtools = mountDevtools({
  container: document.querySelector("#debug-dock"),  // default: document.body
  appId: "aktion-app-2",                             // default: the first app that registered
  tab: "network",                                    // default: last used, else "overview"
  dock: "right",                                     // "float" | "right" | "bottom" | "left"
  theme: "dark",                                     // "system" | "dark" | "light"
  open: false,                                       // default true; false starts as the launcher
  launcher: true                                     // show the launcher pill while minimised
});

Nothing here is required. Section ids are overview, inspect, state, data, routes, timeline, network, console, effects, profiler, a11y, security, test, source, theme and settings. Floating, the panel opens at about 62% × 72% of the viewport near the bottom‑right corner and can be resized down to 380×280. Position, size, dock, theme, density, section sizes and the last section are remembered in localStorage.

The controller

MemberDoes
element, hookThe live panel element; the shared hook.
open(), close(), toggle()Show, minimise to the launcher, or flip. A minimised panel stays subscribed and keeps recording.
selectApp(id), selectTab(tab), dock(position)Switch the inspected app, the section, or the dock position.
importSession(json, fileName?)Open an exported session for offline inspection; returns its id in the app switcher.
flush()Render synchronously — for tests that assert on the panel.
destroy()Remove the panel. It unsubscribes, so the runtime goes dormant again; the hook stays installed.

close() and destroy() are not interchangeable: close() is a UI state, destroy() turns the instrumentation off.

The hook and the app record

const hook = window.__AKTION_DEVTOOLS_HOOK__;

hook.aktion;            // true — the marker that says you found the real hub
hook.protocolVersion;   // 3
hook.libraryVersion;    // the runtime's VERSION, e.g. "0.7.0"
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.setOptions({ captureProps: false });   // an instrumentation switch
const stop = hook.subscribe((event) => console.log(event.kind, event));
stop();

Events come in eight kinds: commit, state, effect, network, route, emit, log and error. Each entry in hook.apps is a deliberately narrow handle: values are serialised before they cross, every write goes through the same reactive pipeline an event handler uses, and there is no path to the raw runtime internals. Beyond the seven core members every capability is optional, so a frontend feature‑detects and degrades instead of throwing.

MemberGives you
id, label, elementIdentity: aktion-app-1; the data-devtools-label (else the element id); the host element.
getState(), setState(path, value), getProgram(), forceRender()The version‑1 contract, always present.
Program & diagnostics
setProgram, getSources, getDiagnostics, analyzeProgram(text?), reloadHot swap, per-module sources, diagnostics, and a parse + validate + outline of any candidate text.
Inspector
getRenderRoot, getComponentTree, getInstance, instanceForNode, nodeForInstanceThe render root, the instance tree, one instance in full, and the DOM mapping both ways.
setInstanceHook, setInstanceUiState, setPropOverride, clearPropOverride, listPropOverrides, remountInstanceWrites into one instance.
State, effects, data, router, theme, network
getStateMeta, resetState, hydrateState, evaluateExpressionAtom metadata, reset, time travel, the REPL.
getEffects, runEffect; getQueries, refetchQuery, cancelQuery, invalidateQueries; getStores, callStoreMethodEffects, the query cache, stores and forms.
getRoute, navigate; getTheme, setThemeTokens, clearThemeTokens, setThemeName; setNetworkRules, getNetworkRules; getStatsRouter, theme, request rules, counters.
Protocol 3
getReactivityGraph()Who reads what: atoms and their derivations, component instances and their dependencies, effects and their triggers.
getSecurityProfile()What the program text can reach, from its AST: the access policy, host globals, dynamic code, escape hatches, endpoints, opened URLs, emitted events, storage.

With several apps on a page, name them — otherwise the switcher reads aktion-app-1, aktion-app-2:

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

How it works

CapLimit
Hook event buffer2000 events (hook.bufferLimit)
Commits / effect events / requests per app300 / 600 / 300
Console lines per app500, consecutive duplicates collapsed
Navigations / emitted events / errors200 each
State snapshots for time travel60 commits
Changes remembered per atom60
Elements examined by the accessibility audit4000 — it says when it stopped

What it costs when it is not open

StateisDevtoolsActive()What the runtime pays
You never import aktion-runtime/devtoolsfalseOne property read per emit site. No records, no panel code in the bundle.
Hook installed, nothing subscribed (including after destroy())falseThe same single property read.
A panel is mounted — even minimisedtruePer-commit timing and one record per instance, a snapshot per flush, and whatever the instrumentation switches are set to. A minimised panel does not render or sample frames.

Do not ship it enabled

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

Troubleshooting

No panel appears

“No Aktion app on this page yet”

A section is empty

An edit does not stick

Next