Skip to content

Architecture — the @nejcm/dev-toolbar shell

This is the reference for the shipped core: what the shell guarantees, where its boundaries are and why they sit there, the full styling surface, and how to write an extension against it. The README documents the package for consumers; this document is the why, and is the thing to read before changing the contract.

  • Contract version: 2 (CONTRACT_VERSION, in src/core/contract.ts)
  • Entries: @nejcm/dev-toolbar (root), /runtime, /kit, /ext/metrics, /ext/environment, /ext/flags, /ext/command-menu, /ext/overlays, /ext/diagnostics, /ext/theme-editor, /ext/agent, /ext/a11y, /testing, /styles.css
  • Runtime dependencies: none. One optional peer, axe-core, reached only by /ext/a11y and only through import() — see ext/a11y.md for what installing it costs

src/core/contract.ts is the source of truth for the types. Where this document and that file disagree, the file is right and this document is a bug.

A note on plans/. The product design (dev-bar.md), the accepted delivery plan (implementation.md) and the per-phase working notes (architecture.md) are local scratch, not tracked in git. Doc comments in src/ still cite them by section — per plans/dev-bar.md §3D — as provenance for why a feature has the shape it has. Those citations are historical markers, not links; everything that survived as a rule is here, in the README, or in CONTRIBUTING.md.

1. What the shell is

The root entry is chrome plus hosting, and nothing else. It:

  • renders a fixed bar at the top or bottom of the viewport, in a portal on document.body;
  • sorts the extensions it is given by align, then order;
  • collapses the lowest-priority items into a menu when the bar runs out of width, and lets them back out when the width returns;
  • hosts at most one panel at a time, resizable and persisted;
  • renders every extension's overlay slot, uncollapsed, for modal surfaces;
  • publishes --dev-toolbar-height-<instanceId> per instance, and the unsuffixed --dev-toolbar-height while exactly one is mounted, and ships an opt-in <DevToolbarInset>;
  • owns the token set, the data-dtb-part attributes and the classNames map;
  • persists visibility, position, active panel and panel height through an injectable storage adapter;
  • wraps every extension slot in its own error boundary;
  • runs start(api) once per extension and reports visibility to it;
  • aggregates extension-declared commands and diagnostics, and exposes them — it renders neither a palette nor a snapshot.

It does not know what a metric is, what a flag is, what "healthy" means, who the user is, or what may be shown to them. Every one of those is an extension's job.

Everything the root entry exports is public and versioned, escape hatches included — CORE_CSS, ensureStyles, the storage adapter factories, STORAGE_PREFIX, DEFAULT_SHORTCUT and the panel-height bounds. The list, one line each, is docs/api.md § Other exports. The aggregation functions themselves are not exported: collectCommands and collectDiagnostics only ever see the array they are handed, while api.getCommands() / api.getDiagnostics() and useToolbarCommands() see the merged list the toolbar actually renders.

2. Boundary rationale

Light DOM, not Shadow DOM

The obvious way to keep a dev toolbar from colliding with its host app is a shadow root. We deliberately do not do that.

Extensions are user code. Inside a shadow root:

  • Tailwind, and every other utility framework, stops working — its rules live in document.head and do not cross the boundary;
  • most CSS-in-JS runtimes inject into document.head too, so styled components render unstyled;
  • an extension's own createPortal targets (document.body) escape the shadow root and lose the styles it expected;
  • design-system components that measure themselves, or rely on :root variables, behave differently than they do anywhere else in the app.

Isolation that only the shell's own styles enjoy is not worth taxing every extension author. So the shell renders in the light DOM and buys its isolation a different way:

css
@layer dev-toolbar {
  [data-dev-toolbar] { … }
}

Two properties fall out of that, and they are the whole style contract:

  1. Scoping. Every core rule is prefixed with [data-dev-toolbar], so core never touches app markup.
  2. Losing on purpose. Every core rule is inside a cascade layer, and unlayered author CSS beats any layered rule regardless of specificity. A consumer's one-class selector overrides core's two-attribute selector without !important.

The playground exercises this both ways: a Tailwind-classed extension renders correctly (the regression test), and the playground's own stylesheet restyles the bar with no !important anywhere.

Losing on purpose has a consequence worth stating, because it is the one most consumers meet: a CSS reset is unlayered too. Tailwind's Preflight ships button, input, optgroup, select, textarea { padding: 0 } and button { background-color: transparent }, and both beat core's layered rules. In a Tailwind app that means every trigger, the button and every control inside a panel render with no padding, no hover background and no selected background — core's look silently reset. Layout survives, because gaps, heights and dividers are properties no reset touches; only each control's own box is flattened.

This is the contract working, not a bug, and the fix belongs on the consumer's side, where the reset came from. The whole remedy is revert-layer:

css
/* Unlayered, so it beats Preflight; resolves to whatever @layer dev-toolbar
   would have produced for that element in that state. Reverting by toolbar
   attribute, not by element, is the whole design: Preflight resets every
   control the toolbar draws plus the margins and list markers on its headings,
   paragraphs and lists, and these two attributes cover all of them at a
   specificity Preflight cannot reach — while leaving alone any element the
   toolbar does not own, so a consumer's own utility classes on their own chip
   still win. Both attributes are needed: a control from the kit carries
   data-dtb-kind and need not carry a part, and the kit's rules lose to a reset
   exactly as core's do. :is() takes its specificity from its widest argument,
   so this is the same weight as the part-only selector it replaces. */
[data-dev-toolbar] :is([data-dtb-part], [data-dtb-kind]) {
  margin: revert-layer;
  padding: revert-layer;
  background-color: revert-layer;
  font-size: revert-layer;
  line-height: revert-layer;
  letter-spacing: revert-layer;
  color: revert-layer;
  list-style: revert-layer;
}

/* Preflight's border reset is `*, ::before, ::after { border-width: 0 }`, so
   this one has to be as wide to win. It erases the bar's own edge, the popup,
   panel rules and the promoted pill, and repaints what survives Tailwind grey. */
[data-dev-toolbar],
[data-dev-toolbar] *,
[data-dev-toolbar] *::before,
[data-dev-toolbar] *::after {
  border-width: revert-layer;
  border-style: revert-layer;
  border-color: revert-layer;
}

Reverting is safe because core states the same reset itself — :where(button) and :where(menu, ol, ul) in src/styles.css give every unstyled button and list inside the toolbar a deliberate zero, so revert-layer never falls through to a UA 1em or a grey button face in a host that ships no reset either.

Hover, [aria-expanded="true"] and :active all come back without being restated, because revert-layer re-runs the layered cascade per element and per state — so the remedy keeps tracking core if core's values change. No !important. examples/playground/src/playground.css carries exactly this block, which is why the playground shows the bar as core styles it despite loading the Play CDN.

Recorded as ADR-002.

No global registry

Extensions arrive as a prop (extensions={[…]}), plus useDevToolbar().register() for anything scoped to a mounted subtree. There is no module-level registry, because one would:

  • break SSR — module state is shared across requests on a warm server;
  • break two toolbars on one page — a second root would see the first one's items;
  • leak between tests — registration would outlive the test that did it;
  • make ordering depend on import order, which nobody controls.

Two toolbars on one page therefore have to share the one thing core does write globally: the height custom property on <html>. Each instance owns --dev-toolbar-height-<instanceId> (the id folded to A-Za-z0-9_-) and removes only that on unmount. The unsuffixed --dev-toolbar-height — what a consumer who never thought about instances reads — is published while exactly one instance is mounted and enabled, whichever it is, and withdrawn while there are two. <DevToolbarInset> pads by its own instance's name and falls back to the unsuffixed one. Two instances sharing an instanceId still collide on the suffixed name — the same reason they may not share one for persisted preferences (§3).

Core therefore keeps two module-level structures, neither of them a registry of extensions:

  • The command host set in src/core/commands.ts. Entries are added on mount and removed on unmount, and it exists only so the context-free runCommand(id) can reach a mounted toolbar. Inside React, prefer useDevToolbar().runCommand.
  • The mounted-instance set in src/core/useHeightVariables.ts, which decides who owns the unsuffixed height name. It holds one token per mounted, enabled toolbar — tokens, not ids, so two instances wrongly sharing an id still count as two — and lives on globalThis under a Symbol.for key, like the measurer slot, so two copies of core on one page (the dual-package hazard, or a duplicated dependency) share one count instead of each believing itself alone. That makes the token's shape a small compatibility protocol between copies: compatible copies agree; a copy from before this rule does not participate. The unsuffixed name is re-derived from the whole set after every registration, removal and measurement, so the order React runs different instances' effects in cannot matter — no instance decides at its own mount whether it is alone, and none removes the name on its own account. Within an instance the order is load-bearing: the registering effect is declared before the measuring one, because React runs a component's effects in declaration order and the first measurement reports to the token registration created. Swapping them loses the publication whenever both effects re-run in one commit (re-enabling a mounted toolbar); the "publishes on re-enable" case in DevToolbar.test.tsx fails if they are swapped.

Both survive SSR because neither is touched during render, only from effects, and both are emptied by effect cleanup; the test harness additionally clears the instance set after every test, for the mount whose unmount threw.

Recorded, with the contract shape it follows from, as ADR-001.

Core never imports runtime/ or ext/

An event bus, ring buffers and a throttled store are what a collector needs, not what chrome needs. Putting them in core would mean every consumer downloads the machinery for measurement even when their toolbar is three buttons. They ship as an opt-in ./runtime subpath, which core never imports.

The same applies in reverse to ./testing: it reaches nothing but core, so it stays usable without /runtime. It reaches core through the package's own specifier (@nejcm/dev-toolbar, marked external in tsup.config.ts) rather than a relative path, for the reason in §7 — CJS output has no code splitting, so a relative value import is inlined, and a CommonJS consumer mixing . with ./testing would get two cores, two React contexts and a useDevToolbar() that throws inside renderWithToolbar(). Types erase and carry no instance identity, so those stay relative. src/core/__tests__/boundary.test.ts asserts the import shape and the built bytes; test/fixtures/jest-consumer/shared-instance.test.js asserts the consequence in a real CommonJS consumer.

This rule is why core cannot redact anything it aggregates — see §10.

enabled, not build magic

There is no bundler plugin and no __DEV__ global. enabled={false} renders children and nothing else: no portal, no lifecycle, no listeners, and any running extension is torn down. For byte-level stripping, the consumer does it with tools they already have — see the recipe in the README.

Visibility is reported, never acted on

start(api) gets isVisible() and subscribeVisibility(). Core never pauses an extension on its behalf. A cumulative counter that silently stops counting when the bar is closed is worse than one that keeps going, and only the extension knows which of its work is cumulative. subscribeVisibility()'s subscription is released automatically when api.signal aborts, so an extension that keeps only the signal for cleanup does not leak one per remount.

isVisible() is about the bar, not the document. Whether the tab is backgrounded is document.visibilityState, it is not core's to report, and it is a different question with different consequences — /ext/metrics reads both, and only the second one makes it throw frames away.

hidden is not visibility

hidden is the consumer saying this extension does not exist for this actor. Core therefore treats it as absent everywhere, not merely unpainted.

Concretely, a hidden extension:

Enforced in
does not render in the bar or the menuBar.tsx / sortExtensions
is never start()ed, and is torn down if it becomes hidden while runninguseExtensionLifecycle.ts
has no panel mounted, keepMounted includedPanelHost.tsx
has its activePanelId cleared on the transitionuseExtensionLifecycle.ts
contributes no commands and no diagnostics to the aggregationscommands.ts / diagnostics.ts

Getting only the first two right is worse than getting none, because it looks enforced. A hidden collector that still ran would keep fetch patched, keep retaining request URLs and keep a requestAnimationFrame loop alive for somebody not permitted to see any of it. A panel left mounted keeps the request table on screen. A command left in the aggregate means runCommand("metrics.copy") still puts that table on the clipboard — a front-door bypass of the whole thing.

The activePanelId clear is narrow on purpose: only a present and hidden extension closes its panel. An id that is merely absent is left alone, which is what lets a persisted activePanelId survive until the extension that owns it registers.

That distinction is also the answer for a consumer still waiting on the permissions that decide hidden: leave the extension out of the array while you do not know, rather than passing hidden: true. Absent preserves the persisted panel; a transient hidden: true closes it, because as far as core can tell you have just said this actor may not see it.

3. State, storage and lifecycle

Core-owned state lives in a small store read through useSyncExternalStore. activePanelId and panelHeight are always store-owned. visible and position may be controlled by <DevToolbar> props. When controlled, the context exposes the prop value for rendering while store.getSnapshot() continues to hold the persisted or uncontrolled value, so those two values can differ. The matching context setters call their change callbacks and do not write the store while controlled.

Four keys persist when their state is changed through the store:

KeyValue
dtb:v1:<instanceId>:visibleboolean
dtb:v1:<instanceId>:position"bottom" | "top"
dtb:v1:<instanceId>:activePanelstring | null
dtb:v1:<instanceId>:panelHeightnumber, clamped to 160–800

Each extension's start(api) gets api.storage, scoped to dtb:v1:<instanceId>:ext:<extensionId>:.

: is the delimiter and is not escaped: instanceId and extension ids are joined into the key as-is, so one containing : can alias another instance's or extension's scope (instanceId: "a:ext:b" reads and writes the same keys as instanceId: "a" with extension id "b"). ADR-001 already treats id collisions as the consumer's responsibility; the same applies here. Keep both ids to [A-Za-z0-9_-] — the set instanceHeightVariable (useHeightVariables.ts) already folds non-conforming instanceIds down to.

The adapter is a synchronous three-method interface (getItem/setItem/removeItem), which is exactly localStorage's shape — that is why localStorage is the default. storage={null} disables persistence. A throwing adapter does not take down the render. Core preference reads degrade to defaults; extensions may distinguish an unreadable adapter and preserve their session state instead.

The default createLocalStorage() adapter converts every localStorage failure to "nothing stored" by design. An extension that distinguishes "unreadable" from "absent" therefore sees that difference only when a consumer-supplied adapter throws. Such an extension can preserve session state across another start() on the same runtime object; readable-but-empty storage deliberately clears the extension's override or edit map, and a new runtime or page reload cannot recover an in-memory map that failed to persist. Surfacing failures from the default adapter would change what every third-party api.storage.getItem caller sees and is not proposed.

storage and instanceId are read once, on mount. The store, the context's storage and every extension's namespaced view all derive from that single captured adapter, so they can never disagree about where preferences live. To move an instance to a different namespace, remount it (key={instanceId}).

Lifecycle order for one extension:

  1. It appears in the merged extension list (props first, then dynamic registrations, de-duplicated by id) and is not hidden.
  2. start(api) runs once. Its return value, if a function, is the cleanup.
  3. It renders compact (in the bar or in the menu), overlay (always, while the bar is visible) and, when its panel is active, panel.
  4. When it leaves the list, becomes hidden, enabled flips to false, or the toolbar unmounts: api.signal aborts, then the cleanup runs.

Where start() sits relative to the first render

On the initial mount, start() runs before the first compact. The bar is gated on a client-mount flag flipped in an effect, so the first commit renders no slots at all and the lifecycle effects win the race.

That is a consequence of the mount gate, not a guarantee of the contract, and it does not hold when an extension appears later — enabled flipping from false to true, or hidden from true to false, renders the slot in the same commit whose effects will call start(). So the rule for extension authors is unchanged and unconditional: anything a slot reads must exist by the time the factory returns. Both orderings are pinned by tests in src/core/__tests__/lifecycle.test.tsx.

Extension objects must be referentially stable

id identifies an extension, but the object owns its lifecycle: start() was called on one particular object, and whatever it created lives in that object's closure. Rebuild the object and the bar renders a second one that owns nothing, while the first keeps running unreachable.

Core cannot fix this — the identity is the consumer's — so it detects it. When an already-started id turns up with a different start function reference, core warns once for that id. The discriminator is deliberate: {...ext, hidden: true} keeps the same start reference and stays quiet, while extensions={[metrics()]} written inline gets a new closure every render and does not.

The first thing this caught was not a render loop at all. It was a Vite hot-module reload of the playground's extensions.tsx, which re-evaluated the module, built a second extension, and left the chips frozen while the first one carried on collecting. The warning says so, and says to reload the page.

Render-phase ref writes

Three sites write to a ref during render instead of in an effect, each carrying an oxlint-disable (or -next-line) comment for react/refs. An effect always runs a render behind the render that scheduled it; each of these three needs the value current in the same commit that reads it, so an effect would be one render late. Two of the three are pinned under <StrictMode> in src/core/__tests__/strict-mode.test.tsx, which double-invokes render (not commit) and is exactly the thing that would expose a stale or duplicated write.

The general hazard a render-phase write invites: React may render without committing (a discarded speculative render, or <StrictMode>'s double-invoke in development), so a write that only makes sense for a committed render can record state for a render that never happened. Each site below is safe for a different reason, stated as the invariant that has to keep holding for it to stay safe.

SiteRecordsInvariant that makes it safe
useCommandHost.ts, extensionsRef (extensionsRef.current = extensions)The merged extension list, for getCommands()/getDiagnostics() to re-enumerate imperatively.The write is a pure, unconditional overwrite of the previous value with a value derived only from this render's props/state. A discarded render's write is simply replaced by the next render's write before anything imperative reads the ref — nothing observes the intermediate value.
Overflow.tsx, listRef (listRef.current = all)The current [...startItems, ...endItems], so recompute (called from a ResizeObserver effect) reads the live list without depending on it and re-subscribing every render.Same shape as extensionsRef: an unconditional overwrite of a value that is a pure function of this render's props. recompute only runs from the ResizeObserver callback and the layout effect below it, both of which fire after commit, so they only ever see the value from a render that committed.
PanelHost.tsx, openedRef (opened.add(id) / opened.delete(id))Which panel ids have ever been opened, so a closed keepMounted panel stays mounted.Different shape from the other two: this mutates a persistent Set in place rather than overwriting the ref, so a discarded render's mutation is not automatically superseded by the next render the way a plain overwrite is. What keeps it safe is that activePanelId reaches this component only through useSyncExternalStore (read in DevToolbarRoot, passed to PanelHost), which opts store-derived props out of concurrent/deferred rendering, and core uses neither startTransition nor useDeferredValue — see below for what a hypothetical abandoned render would cost anyway.

Pinning tests: "keeps getCommands() current despite the doubled render-time ref write" (extensionsRef), "keeps the render-time openedRef bookkeeping in PanelHost correct" (openedRef), both in strict-mode.test.tsx. Overflow.tsx's listRef has no dedicated StrictMode test; the overflow suite (src/core/__tests__/overflow.test.tsx) exercises recompute reading through it but not under <StrictMode>.

No render path in this codebase can actually produce an openedRef mutation ahead of the committed render: useSyncExternalStore forces activePanelId to stay synchronous with the store, and nothing under src/core calls startTransition or useDeferredValue to defer it. Even in the hypothetical where a consumer's own concurrent-mode usage produced an abandoned render anyway, the blast radius is small — one keepMounted panel mounting a render early with hidden={!isActive}, which self-corrects the next time that panel closes.

4. Style API

Three surfaces, in the order you should reach for them.

4.1 --dtb-* tokens

Set them on [data-dev-toolbar], or on any ancestor. Unlayered CSS wins.

TokenDefault (light)Purpose
--dtb-font-familysystem sans stackPanel prose. Not the bar — see below
--dtb-font-monosystem mono stackThe whole bar, plus values and error chips in panels
--dtb-font-size11pxBase size (12px when comfortable)
--dtb-bar-height32pxBar row height (38px when comfortable)
--dtb-radius5pxCorner radius on triggers, menu, chips
--dtb-gap5pxGap between the popup's rows (7px when comfortable)
--dtb-item-gap18pxGap between bar items (24px when comfortable), with a centred divider. Read back for collapse math; an override applied after mount reaches the decision — when it widens the gap, on the next bar reading or commit when it narrows one. See §5
--dtb-chip-gap6pxLabel-to-value gap inside one chip, and between two controls one extension renders
--dtb-padding-x8pxBar's horizontal padding. The panel body takes --dtb-panel-padding-x
--dtb-item-padding-x7pxTrigger padding (9px when comfortable)
--dtb-space-1--dtb-space-54/8/12/16/24pxThe spacing scale every panel and popup measures in
--dtb-panel-padding-x14pxPanel body's inline margins (18px when comfortable)
--dtb-panel-padding-y12pxPanel body's block margins (14px when comfortable)
--dtb-control-height22pxMin height of a button, tab or switch in a panel (26px when comfortable)
--dtb-control-padding-x10pxTheir inline padding (12px when comfortable), and a row's
--dtb-field-padding-x / -y8px / 3pxInputs and selects (4px block when comfortable)
--dtb-menu-padding4pxThe popup's inner frame
--dtb-z-index2147483000Toolbar root stacking
--dtb-bg#f6f6f7Bar background
--dtb-fg#202124Foreground
--dtb-muted#5f636aSecondary text — the weakest foreground, so it bounds how dark a state ground may go
--dtb-borderrgba(0,0,0,.12)Panel and menu borders, inputs, item dividers
--dtb-bar-borderrgba(0,0,0,.07)The bar's own edge — half --dtb-border's strength (rgba(255,255,255,.07) on dark)
--dtb-accent#4652c9Focus ring, resizer highlight, "override" severity
--dtb-item-bgtransparentTrigger background
--dtb-item-hover-bgrgba(0,0,0,.04)Trigger hover
--dtb-item-active-bgrgba(0,0,0,.08)Trigger with its panel open — a neutral darker ground, not a tint
--dtb-item-pressed-bgrgba(0,0,0,.12)Trigger while the pointer is down
--dtb-panel-bg#ffffffPanel background
--dtb-menu-bg#ffffff menu background
--dtb-field-bgrgba(0,0,0,.035)Inputs, selects and textareas — a recessed well, not an outlined box
--dtb-shadow0 6px 24px rgba(0,0,0,.14)Floating surfaces: the command palette dialog, overlay labels
--dtb-menu-shadow0 2px 10px rgba(0,0,0,.08)The popup, which is flush and bordered so it needs only a hint of lift
--dtb-danger#b53628Error chip text; "bad" severity
--dtb-danger-bgrgba(181,54,40,.12)Error chip background
--dtb-ok#187345"ok" severity
--dtb-ok-bgrgba(24,115,69,.12)
--dtb-warn#855b0a"warn" severity
--dtb-warn-bgrgba(133,91,10,.14)
--dtb-panel-heightset per panelWritten by the panel host; read, do not set

Dark values are applied for [data-dtb-color-scheme="dark"] and, under prefers-color-scheme: dark, for anything not explicitly "light".

Every colour above is a text colour somewhere, and the bar's text is 11px compact and 12px comfortable — both under WCAG's 18.66px large-text threshold, so each owes 4.5:1 against every ground it is painted on: the bar, a panel, and its own -bg tint composited over either. --dtb-ok, --dtb-warn, --dtb-danger and --dtb-accent are the original hues at the lightness that ratio allows, picked as a set so no one severity reads heavier than its neighbours. The dots and sparkline strokes drawn in the same colours are non-text, and owe 3:1.

The three item grounds are part of that arithmetic, not separate from it. They are painted under a chip's own text, so the selected state is a ground every foreground has to clear: at the .13 black this used to be, a trigger with its panel open pulled --dtb-muted to 3.47:1 and --dtb-warn to 2.82:1. Hover, selected and pressed are now three even 4% steps — 246 → 236 → 226 → 216 on the bar — which stays legible as a ramp and is shallower, not free: selected still costs --dtb-muted 0.91 (5.59 → 4.68) where the old ground cost 1.19 (4.67 → 3.48). What clears AA is the sum of both halves — a shallower ramp and foregrounds moved down to meet it. The margin left over is thin by design: on the selected-trigger ground the family has 0.05–0.11 to spare, so a later nudge to --dtb-ok, --dtb-bg or the active alpha is expected to trip the guard below rather than pass quietly.

A pressed trigger is AA non-conformant by design. It is not a near miss and it is not measured: no value of --dtb-item-pressed-bg both clears 4.5:1 under every foreground and stays a visible third step past hover and selected. It lasts only while the pointer is held, and axe never evaluates it.

Nothing catches a regression here in a browser: /ext/a11y excludes the toolbar from its own scans, so a consumer's axe run never measures the bar. src/core/__tests__/contrast.test.ts computes the ratios from the declarations in src/styles.css instead, and fails if a token drops below its floor — which is also the check a consumer's own override should be held to.

That file measures the tokens against core's surfaces. An extension sheet that stacks one token's tint on another builds grounds core never sees, and owes its own guard: /ext/theme-editor tinted a token row by severity and then painted a tinted tag inside it, so an edited tag on an overridden row read at 4.36:1 until the row was made the owner of its tint (src/ext/theme-editor/__tests__/contrast.test.ts). Two tokens are deliberately outside both: --dtb-border and --dtb-bar-border separate surfaces that are already told apart by their grounds, so they are treated as decorative under 1.4.11 — a judgement, not a measurement.

The bar is monospace end to end, labels included, and that is deliberate: one family is the only way to get one baseline. Two families at one size do not share one. In an identical 15.4px line box ui-sans-serif puts its baseline 11px from the box top and ui-monospace puts it 10px down, so a sans label and the mono value beside it render a pixel apart. Both font bounding boxes are 13px tall and land on the same top, so the mismatch survives any box-level check and only a baseline probe finds it — which is how it shipped in the first place. Label versus value is carried by colour instead: --dtb-muted label, --dtb-fg (or a severity colour) value. The popup is a DOM child of the bar and inherits the same family.

Panels keep --dtb-font-family, because a panel is prose rather than a row of readouts; inside one, identifiers and values still take --dtb-font-mono the way inline code does in running text.

Every rule — core's and the first-party extensions' alike — uses logical properties (inset-inline, inset-inline-end, margin-inline-start, padding-inline-start, text-align: start, and flexbox's own direction-aware flex-end) instead of left/right, so the bar, the popup and every extension's chips and panels mirror correctly under dir="rtl" even though RTL is not otherwise tested. A regression table test (src/ext/__tests__/stylesheets.test.ts) asserts every exported CSS string contains no physical directional property; every extension stylesheet except the overlays host-outline sheet, which is deliberately unlayered, is wrapped in @layer dev-toolbar and scopes every rule under [data-dev-toolbar]; core's byte-identity check lives separately in src/core/__tests__/css.test.ts. Two deliberate kinds of exception are whitelisted precisely where they occur:

  • Horizontal centring stays physical. /ext/command-menu's dialog and /ext/overlays' grid overlay and notice centre themselves with left: 50% plus transform: translateX(-50%), which is already symmetric under dir="rtl" and needs no mirroring. inset-inline-start: 50% is not an equivalent: under dir="rtl" it resolves to the right edge landing at the midpoint, while translateX(-50%) — evaluated against the element's own physical box, not the logical one — still shifts left by half the width, so the element ends up a full width off-centre. left/right are correct here on purpose.
  • JS-measured geometry stays physical. /ext/overlays' drawing surface also positions boxes and labels from getBoundingClientRect() — a physical, viewport-relative measurement — via inline left/top styles in ui.tsx, which stay physical because the coordinates they mirror are.

4.2 data-dtb-part

Every part carries a stable attribute. These are the supported selector hooks; class names inside core are not. The table below is core's parts — the unprefixed names. An extension's own parts are namespaced and documented on that extension's page in docs/ext/, which is where the icon and text parts the presentation option added are listed too; this table does not grow when an extension adds one.

PartElementNotes
rootportal rootAlso data-dev-toolbar, data-dtb-instance, data-dtb-position, data-dtb-density, data-dtb-color-scheme
barthe bar rowrole="toolbar"
regionone align regiondata-dtb-align="start" | "end"
itemone extension's compact slotdata-dtb-ext-id, data-dtb-align, data-dtb-overflowed, data-dtb-panel-open
triggercore's default button/labelOnly when the extension supplies no compact
overflow-buttonthe buttonaria-expanded, aria-controls while open
overflow-menuthe popoverrole="group", labelled, tabindex="-1"
overflow-menu-itemone collapsed item wrapperdata-dtb-ext-id
overlayone extension's overlay slotdata-dtb-ext-id
panelone paneldata-dtb-ext-id, data-dtb-active, hidden when inactive
panel-resizerdrag/keyboard handlerole="separator", arrow keys resize
panel-bodyscroll container
error-chipa crashed slotdata-dtb-ext-id, data-dtb-slot="compact" | "panel"
error-retrythe chip's retry buttonAbsent in the overlay slot
inset<DevToolbarInset>data-dtb-position

Two attributes are not parts but treatments an extension opts into, both owned by core so every panel divides the same way. The extension keeps its own data-dtb-part on the same element for targeting.

AttributeOnEffect
data-dtb-legenda section headingSmall mono caps with a hairline running from the word to the end of the measure
data-dtb-bleeda panel's scroll container, or a fixed region with a ruleReaches the panel's inline edges and re-applies the body's padding inside, so a scrollbar sits on the panel edge and a toolbar's or footer's rule runs the panel's width
data-dtb-embedthe root of a third-party tool's subtreeOpts the subtree out: core's element-level defaults — box-sizing, the heading/list margin resets, the button face, field geometry, the focus ring — are each guarded with :where(:not([data-dtb-embed] *)) and stop at it, so the tool arrives with the UA's defaults and its own CSS. Inheritance still flows in. embedding.md

data-dtb-bleed must not be nested inside another bled element — a second bleed overflows rather than aligning, which is why a legend's rule stops at the measure.

A second attribute cuts the other way. data-dtb-part says which part this is; data-dtb-kind says what sort of thing it is — action, chip, dot, glyph, label, value, note, tag, row, rows, stack, list, empty, banner, search, toolbar, field. Parts are namespaced per extension and so cannot be styled across extensions in one rule; kinds are shared and exist precisely for that. KIT_CSS, from @nejcm/dev-toolbar/kit, is the one stylesheet keyed on them, and it replaced a button reset that had been hand-copied into five extensions and had already drifted three ways. Severity rules there are compound[data-dtb-kind="dot"][data-dtb-severity="warn"], both attributes on one element — so a container carrying a severity never tints its descendants. field is a kind with no kit rule at all: core's :where(input, select, textarea) already owns field geometry, so that seventeenth kind is a selector hook and nothing more. The kit's sheet is subject to the same three gates as core's and every extension's — logical properties only, @layer dev-toolbar, [data-dev-toolbar]-scoped — enforced by src/ext/__tests__/stylesheets.test.ts.

Core owns the unprefixed names; an extension that ships CSS namespaces its parts by kind — a prefix fixed by the extension package, not by the id an individual instance happens to carry — which is why /ext/metrics renders data-dtb-part="metrics-chip" and not data-dtb-part="chip". Without a prefix the attribute stops being a stable hook the moment two extensions pick the same word.

By kind, not by id, on purpose: metrics({ id: "metrics-api" }) and metrics({ id: "metrics-worker" }) are two instances of one thing, and a consumer styling metrics chips wants one rule for both. The instance is already addressable — [data-dtb-ext-id="metrics-worker"] sits on the surrounding item — so nothing is lost, and data-dtb-metric narrows further to a single chip.

Core's injectStyles prop is a prop, so an extension cannot see it. An extension that ships CSS therefore needs its own switch — metrics({ injectStyles: false }), environment({ injectStyles: false }) — and should export its stylesheet as a string for consumers who deliver CSS themselves. Threading core's flag down would mean extensions importing core's React context at runtime, which only works if both resolve to the same module instance; see §7.

The injection itself is shared: ensureStyleSheet(entry, css, doc?, nonce?) in /runtime. It keys on the data-dev-toolbar-styles attribute by comparing the attribute directly rather than interpolating entry into a selector string, so an entry containing a quote can't be mistaken for another entry's element or throw a SyntaxError; the DOM rather than a module flag is the deduplication truth, so two bundled copies still inject once. The optional nonce sets the element's nonce property for hosts running a nonce-based CSP. It sits in /runtime rather than core because importing core's injector would drag core's whole stylesheet string into an extension's bundle — and extensions already import /runtime, while core never does.

4.3 classNames

A narrow map, for when you want your own class on a part: root, bar, region, item, overflowButton, overflowMenu, overflowMenuItem, overlay, panel, panelResizer, errorChip.

It is deliberately not open-ended — adding a slot is a contract change, which is the point.

4.4 CSS delivery

Styles are injected once per document, from inside a component, keyed on a style[data-dev-toolbar-styles] element in document.head. The DOM is the deduplication source of truth rather than a module flag, so two bundled copies of the package still inject once. injectStyles={false} opts out; import @nejcm/dev-toolbar/styles.css instead. sideEffects: ["*.css"] stays accurate because nothing is injected at module scope.

src/core/css.ts is a hand-maintained byte-identical copy of src/styles.css, enforced by src/core/__tests__/css.test.ts (which asserts the two are identical); there is no generator. To change the styles, edit src/styles.css and paste its contents into the template literal in css.ts. src/styles.css is excluded from the formatter so the bytes stay identical.

5. Layout and overflow

align picks a region ("start" default, "end"), order sorts within it ascending, and priority decides what collapses when the bar is too narrow — lowest priority collapses first, ties break toward the later item.

OverflowBar feeds committed readings to the framework-free CollapseMachine, which owns cached widths, priority order, region-emptiness hysteresis and cycle detection. Cached widths are sticky: only positive readings replace them, so a collapsed item can return even though its host is absent from the bar. Cycle detection can also delay a genuine shrink: a chip growing from 100 to 320 px and back at a fixed 500 px bar returns to a previously seen decision, so re-expansion waits for a bar or roster change.

The internal domMeasurer in src/core/measurer.ts is the default adapter for every pixel read behind overflow and height publication; a measurer registered for a test owns those reads instead while it is installed. Bar width remains the padding-box width; subtracting domMeasurer.padding(bar), the measured horizontal padding gives the content width. Item and button widths retain layout sizing, unaffected by CSS transforms. Root height retains the bounding-rectangle measurement. The machine also reserves gaps for empty regions; the Measurer does not duplicate that state-dependent arithmetic. The width items may fill is the bar's clientWidth less its horizontal padding and one gap for each side whose gap the item math does not already charge: one when the start region renders no items, one when there are no end items at all. A region that renders empty still takes its gap.

The Measurer's observer subscriptions reconcile target differences, so an unchanged host never receives another initial notification from re-observation. ITEM_SELECTOR is exported from the package root and shared with /testing; the regions are observed alongside the hosts (see the gap discussion below).

A --dtb-item-gap override applied after mount updates the collapse decision. It has to arrive by an indirect route: a gap-only change resizes no box of the bar's own, so the bar's own observer never fires for it. Two things make it arrive anyway. Every delivery takes a full bar reading — gap and padding included, not widths alone — and the observed set is the item hosts and both regions. A --dtb-padding-x change also changes the bar's own content box, so it triggers the bar's observer directly.

The regions are in the set because the item hosts are not enough. An item is max-width: 100% of its region, so a host alone in a region narrows when the gap widens, while several hosts sharing one region each keep their own width and their sum overruns instead. The region is the box that moves either way, because the gap between the two regions is taken out of them.

Both were measured in Chromium 153 at 320×800, where the fixture starts with gap 10 px, padding 10 px per side, and [growing, low, agent] in the bar. With the roster split across the regions (/?geometry), changing only the gap to 40 px shrinks the lone end-region host from 80 px to about 69.33 px, and to 140 px shrinks it to about 32 px — an item callback, and no bar callback. With all three in the start region (/?geometry&roster=all-start), the same change to 140 px leaves all three at 100/80/80 px and delivers no item callback at all; the start region goes 280 → 160 px and their contents overrun to scrollWidth 550 px in a 320 px bar. That case is why the regions are observed: before they were, it delivered nothing, the decision kept the cached gap, and low and Bridge clipped under overflow: hidden with no to reach them — the exact symptom this section used to record as a limitation. With the regions observed, the region callback arrives, everything collapses into the , and scrollWidth equals clientWidth. As a control, changing only padding from 10 to 30 px per side keeps the gap at 10 px and the outer width at 320 px, while the bar observer reports content width 300 → 260 px.

The route is guaranteed in the direction that clips, and only in that direction. A gap increase takes width from the regions, so a region shrinks and the delivery arrives. A gap decrease from an already-collapsed state can resize nothing at all: the collapsed hosts are gone, and neither the surviving host nor its region is sized by the gap any more. Measured on the all-start fixture: after gap 40 px settles on [growing], changing only the gap to 0 px delivers no callback of any kind and the bar stays on [growing] — more collapsed than it needs to be, with nothing clipped and every chip in the . The next commit repairs it, because every commit re-reads the bar; opening the is such a commit. A --dtb-padding-x change is the direct route either way. Chromium's isolated geometry fixture asserts all of this, on both rosters.

Feeding the whole reading from these observers is safe only because the bar's width is viewport-driven: the root is position: fixed; inset-inline: 0 and the bar is flex: 0 0 auto in that column flex, so the bar's width cannot change because a chip collapsed. A host that makes the root content-sized would make every such delivery an honest reading and defeat the cycle detection above.

The popup is a disclosure, not an ARIA menu. Its entries are extensions' compact slots, which usually render their own buttons, and a menuitem may not contain interactive content — the menu pattern would put the focusable thing inside the item rather than being it. So the button carries aria-expanded and, while open, aria-controls; the popup is a labelled role="group" with tabindex="-1"; opening it moves focus to the first focusable element inside it, or to the popup itself when there is none; Tab walks the entries as it walks the bar; Escape closes the popup and returns focus to the button; a click outside closes it and leaves focus where the click put it. Escape is handled on document, so an extension whose own surface closes on Escape must call stopPropagation()/ext/command-menu does.

Where there is no ResizeObserver, the bar measures on commits and window resize. SSR and bare jsdom report no positive bar width, so the machine renders everything. @nejcm/dev-toolbar/testing ships installToolbarLayout() to make the collapse testable under jsdom. Its fake ResizeObserver delivers one entry per observed target with a synthetic contentRect, and core reads its pixels through the resolved measurer, which under a live install is the fake's rather than the DOM. Core's own overflow tests use the published fake, so consumers and core test the same measurement seam.

The overlay slot is exempt from all of this. It renders once, uncollapsed, for as long as the extension is present, not hidden and the bar is visible — because a compact item that has collapsed into the menu is not in the DOM at all, so an extension whose surface is a modal would lose it exactly when the window got narrow.

6. Failure isolation

compact, panel and overlay each render inside their own ExtensionBoundary. A throw becomes an error chip carrying the extension's label, with the message as its title; the bar and every other extension keep working. In the compact and panel slots the chip's text is a retry button (data-dtb-part="error-retry", accessible name Retry <label>) that clears the caught error and re-renders the slot — without it a slot that threw on transient state would stay a chip for the toolbar's lifetime, since a panel only recovers by unmounting on close. The overlay chip has no retry: an overlay has no dependable visible surface to click. A throw from start() or from its cleanup is caught and logged, and does not take the toolbar down. A throw from commands() or diagnostics() is contained the same way: core logs once and treats that extension as contributing nothing to that aggregation.

This matters more here than in most libraries: extensions are the product surface, and many of them will be somebody's afternoon experiment.

It is a safety net, not error handling. Do not rely on it.

7. Writing an extension

An extension is a plain object. Nothing needs to be imported from this package except its types.

tsx
import type { DevToolbarExtension } from "@nejcm/dev-toolbar";

interface QueueOptions {
  poll?: number;
}

export function jobQueue({ poll = 5000 }: QueueOptions = {}): DevToolbarExtension {
  // Module-local, per-factory-call state. No global registry, so calling the
  // factory twice gives two independent extensions — mind the ids if you do.
  let depth = 0;
  const listeners = new Set<() => void>();
  const emit = () => listeners.forEach((listener) => listener());

  return {
    id: "job-queue",
    label: "Queue",
    contractVersion: 1,   // core warns, once, if it does not match
    align: "end",
    order: 20,
    priority: 40,         // collapses before higher-priority neighbours
    keepMounted: true,    // the chart below survives closing the panel

    // Background work. Runs once, gets an AbortSignal and namespaced storage.
    start(api) {
      const tick = async () => {
        const response = await fetch("/api/queue/depth", { signal: api.signal });
        depth = (await response.json()).depth;
        api.storage.setItem("lastDepth", String(depth));
        emit();
      };
      void tick();
      const timer = setInterval(tick, poll);

      // Core reports visibility; it never pauses you. Decide for yourself.
      const stopWatching = api.subscribeVisibility((visible) => {
        if (!visible) console.debug("[job-queue] bar hidden, still polling");
      });

      return () => {
        clearInterval(timer);
        stopWatching();
      };
    },

    // Slot functions, not components: core hands down state you cannot
    // otherwise know.
    compact: ({ isOverflowed, isPanelOpen, togglePanel }) => (
      <button
        type="button"
        data-dtb-part="trigger"
        aria-expanded={isPanelOpen}
        onClick={togglePanel}
      >
        {isOverflowed ? "Queue depth" : "queue"} {depth}
      </button>
    ),

    panel: ({ height, close }) => <QueuePanel height={height} onClose={close} />,

    // Aggregated by core. Core renders no palette — /ext/command-menu does.
    commands: [
      {
        id: "queue.drain",
        label: "Drain the job queue",
        group: "Queue",
        run: () => fetch("/api/queue/drain", { method: "POST" }),
      },
    ],

    // Aggregated by core. Core renders no snapshot — /ext/diagnostics does.
    diagnostics: () => ({ depth }),
  };
}

Mount it:

tsx
<DevToolbar extensions={[jobQueue({ poll: 2000 })]}>
  <App />
</DevToolbar>

Rules worth stating explicitly:

  • id is the identity. It keys de-duplication, panel state, per-extension storage, data-dtb-ext-id and the error chip. Keep it stable and namespaced. Changing it is a breaking change for consumers who persisted state against it.
  • hidden is consumer-computed. There is no availability(ctx) — core has no identity, session or capabilities to hand you. Whoever knows the actor computes the boolean.
  • Slot functions must be cheap. They run on every toolbar render. Do the work in start(), or in a component the slot returns. A slot that needs to update faster than the bar re-renders should return a component that subscribes to its own store — that is what createThrottledStore in /runtime is for, and it is why the metrics chips can move without core knowing anything about it.
  • Build the object once, outside render. See §3. Core warns when it catches you.
  • Import only types from this package, if you can. /ext/metrics imports nothing but types from core, which erase at build time, so it is a genuinely external consumer of the same contract a stranger's package uses. The moment an extension imports a value — core's React context, say — it has to resolve to the same module instance as the host's copy of core, which the bundler will not guarantee for a first-party subpath bundled alongside it. This is why api carries getCommands(), runCommand(), invokeCommand() and getDiagnostics(): they are how an extension reads core's aggregations without importing one.
  • commands may be a function. Return a fresh array from it whenever what you contribute depends on state that arrives after the factory ran. Keep it pure and cheap — core calls it during render, twice per render under StrictMode — and keep the order stable, or a palette's list reshuffles under the cursor. Identity is the id, not the object.
  • diagnostics() returns what belongs in a bug report. Pure, cheap, JSON-serialisable, and already safe to leave the machine. The reader redacts it again on the way in; that is defence in depth, not a substitute for redacting at the source.
  • A modal belongs in overlay, not compact.
  • Style with your own CSS. Reuse core's data-dtb-part="trigger" to inherit the bar's look, or ignore it entirely and bring Tailwind. Both work — that is the point of the light DOM. The trigger is your element, though: the kit's <Action> is a panel control and stamps the panel geometry, so build the chip out of a plain <button> with kit controls inside it (docs/kit.md).
  • Register dynamically only for subtree-scoped tools.useDevToolbar().register(ext) returns an unregister function; call it on unmount.

First-party extensions live one directory per extension under src/ext/<name>/, each following the same file convention: index.tsx (the factory), runtime.ts (non-React logic), ui.tsx, types.ts, css.ts, __tests__/. A new extension is a new published subpath. Add it to package.json exports, tsup.config.ts entry, and the two hardcoded subpath lists in src/core/__tests__/boundary.test.ts. dts.entry is derived, knip.json already covers src/ext/*/index.tsx, and extensions need no Vitest alias or TypeScript path mapping. For a non-extension subpath such as /kit, use the six-file checklist in AGENTS.md. Never use a wildcard. Shared extension vocabulary and glue, including useExtensionSurface, lives in the published @nejcm/dev-toolbar/kit subpath. First-party extensions import that package specifier, which stays external in their CJS bundles and resolves to one kit instance; ESM keeps the same package boundary. The kit follows the extensions' rules and one more: types only from core, values only from src/runtime, nothing from a sibling ext/<name>/, no [dev-toolbar/ext/…] marker (the dist scan reads markers as proof one bundle carries no other's code), and no module-level state that would couple otherwise independent extensions. The "extension kit (source)" block in src/core/__tests__/boundary.test.ts enforces those rules.

Testing it: @nejcm/dev-toolbar/testing is the whole surface — renderWithToolbar / mountToolbar, makeExtension, makeCommand, fakeExtensionApi, createMockBus, installClipboard and installToolbarLayout. That page is the reference; three things about it are architecture rather than API.

The fake layout is global state, made safe twice over. It answers core through the measurer slot — Symbol.for("@nejcm/dev-toolbar.measurer"), the same registry key resolveMeasurer() reads — and installs globalThis.ResizeObserver, which jsdom does not have. Both are shared by the whole file, and no DOM read is patched: a consumer's own offsetWidth or getComputedStyle stub is untouched by a live install. installToolbarLayout() keeps a module-level stack of live installs rather than each install remembering "the previous value" — the newest install measures, the globals are written once when the stack fills and put back once when it empties, so restore() is idempotent and order-independent. (Per-install capture was correct only in exact reverse order; drained in insertion order it left one fake installed permanently.) And renderWithToolbar({ layout }) owns the teardown from inside the rendered tree, as an effect cleanup: Testing Library exposes no hook into cleanup(), but it does unmount every tree it rendered, so cleanup(), RTL auto-cleanup and unmount() all unregister the fake whether or not the test remembered to.

mountToolbar()'s tracked list is ours, and nothing tells it about RTL's auto-cleanup, so afterEach(cleanupToolbar) is required rather than tidy — this repo's own vitest.setup.ts calls it ahead of Testing Library's cleanup().

MockClock drives the bus, not the package's timers. BusLike carries no clock; a collector that wants one takes an injected time reader instead (CollectorContext.now() in /ext/metrics/types.ts, faked in network.test.ts as () => clock.t). What nothing first-party accepts is an injected timer: /ext/metrics' polling and createThrottledStore both call the globals directly, so reach for vi.useFakeTimers() for those.

@testing-library/react is an optional peer that only renderWithToolbar needs, and nothing on the subpath imports it statically — so @nejcm/dev-toolbar/testing imports cleanly without it, and renderWithToolbar() throws an actionable message if it is genuinely missing.

The load path differs by module system, deliberately. In an ESM runner it is a cached dynamic import(), so act and render come from the host's own module graph — a createRequire() would hand back a second copy whose cleanup() would not clean up what this render() mounted. Inside Jest's sandbox import() never settles, so the CommonJS build falls back to module.require, which is the runner's resolver and returns the registry copy. That branch is dead in the ESM build, where module does not exist. Failing both, setTestingLibrary(module) supplies it by hand — require("@testing-library/react") under Jest, await import(...) in ESM. The test/fixtures/jest-consumer fixture exists to exercise the CommonJS half.

A different identity question — one React across core, /kit and ext/* when the packed tarball goes through Vite's dependency optimizer — has its own fixture, test/fixtures/vite-consumer. It does not import /testing and says nothing about the resolver above; its README has what it does cover.

7.1 Contract v2 — commands with input and a result

CONTRACT_VERSION is 2. Two things a command could not do before:

ts
interface ToolbarCommand<In = void, Out = void> {
  id: string;
  label: string;
  description?: string;
  group?: string;
  keywords?: string[];
  shortcut?: string;
  input?: CommandInputSchema;
  run(input: In): Out | Promise<Out>;
}

The motivation is recorded in plans/agent-readable-toolbar.md § "The two contract gaps": /ext/flags worked around the missing parameter by enumerating one command per flag per value, /ext/theme-editor could not work around it at all (a design token's value space is open), and runCommand resolving true meant diagnostics.capture wrote to its own store with no way for the caller to read back what it produced.

Compatibility was the deliverable, not the type. Four decisions, each load-bearing:

  • In and Out default to void. A v1 run(): void | Promise<void> satisfies run(input: void): void | Promise<void> with no edit, so every existing extension compiles unchanged and a v1 extension object is a valid v2 extension object. Nothing in this phase is required of a v1 author — including keeping contractVersion: 1, which still only produces core's one-time console warning.

  • A roster is AnyToolbarCommand, not ToolbarCommand. getCommands(), useToolbarCommands() and ToolbarCommandsInput hold commands with differentIn/Out, which needs one element type they all satisfy.

    ToolbarCommand<void, void> cannot be it. The mechanism is worth stating precisely, because the obvious explanation is the wrong one: run is declared with method syntax, so its parameter is compared bivariantly even under strictFunctionTypes — TypeScript tries both directions and accepts either. It is not that contravariance rejects the assignment; it is that neither direction holds for void. void is not assignable to { key: string }, and { key: string } is not assignable to void, so both attempts fail and ToolbarCommand<{ key: string }> is rejected (TS2375). Bivariance is what makes the rest of this work — it is why ToolbarCommand<never, unknown> and ToolbarCommand<any, any> accept commands in both directions at all.

    ToolbarCommand<never, unknown> cannot be it either, and for a different reason: it accepts every command (never is assignable to any parameter), but readonly ToolbarCommand[] — what every v1 consumer wrote — would stop being assignable from it, failing on the return type (TS2322), since unknown is not assignable to void. ToolbarCommand<any, any> is mutually assignable with both, which is exactly the compatibility required; its run(input?: any) keeps command.run() compiling on an aggregated command. It is the only any in the package and it is commented as such. One cast, in invokeCommand, restores the call signature the erasure gave up.

  • runCommand keeps resolving a boolean. It is a published export and a useDevToolbar() member. Widening it to an object would make every if (await runCommand(id)) pass silently — the worst kind of break, because it type-checks. The result comes back through a new method, invokeCommand, which resolves { ok: true, result } or { ok: false, reason: "unknown-command" }. invokeCommand takes an options bag ({ input?, scope? }) at module level rather than a third positional argument, so no existing runCommand(id, scope) call changes meaning.

  • Errors keep one convention per layer. invokeCommand rejects with whatever run() threw, exactly as runCommand always has. /ext/agent is the only place that turns a throw into a value, because that is the boundary where a rejection crossing page.evaluate would arrive as a bare string.

CommandInputSchema is a flat bag of named fields, each a primitive (boolean | string | number, or an array of those for a genuinely polymorphic value), or an enum with its values. No nesting, no composition, no validation vocabulary, no validator. It is not JSON Schema and not Zod because zero runtime dependencies is a rule (§2) and because the schema's job is to describe, not to gate: run() is the only thing that knows what its own input means, so run() is what refuses bad input by throwing a message saying why. The palette and an agent both read the same description; neither enforces it.

/ext/command-menu skips every command that declares input, in one place — its enumerate(). It has no form to collect input with, and both alternatives (a row that cannot run, or a row that runs with undefined and throws) are worse than not listing it. Those commands stay fully reachable through getCommands(), invokeCommand() and /ext/agent. snapshot.commands in that extension therefore means "what the palette could run", not "what exists" — the bridge's listCommands() is the unfiltered list.

Contract v2 is the first bump. It is additive in source terms, so it does not settle ADR-003 — which asks when the number should move, and is still open. The first-party extensions all declare 2 and /ext/diagnostics keeps its hand-maintained TARGET_CONTRACT_VERSION in step with an equality assertion, per §10.

The bridge's reporter, and the half that is deliberately not here

/ext/agent can also push its snapshot off the page: report: { url } starts a check-in loop that POSTs the (already-redacted) snapshot to a same-origin dev-server route and picks up commands that route has queued. Writes go through createThrottledStore from /runtime, so a burst is one POST and an unchanged snapshot is none. It is off by default, and allowRun still decides whether a queued command can run at all — a bridge without it answers { ok: false, reason: "run-not-allowed" }.

The receiving half is not in this package and there is no ./vite subpath. It lives in examples/playground/plugins/devToolbarAgent.ts as a recipe to copy. A bundler plugin here would be a new coupling and a dependency-shaped one, against §2, for a surface that is a development convenience; it gets promoted when a consumer asks. The middleware runs arbitrary registered commands on the developer's open page, so it is apply: "serve" only, refuses to install on a non-loopback bind, refuses a non-loopback Host (the rebinding guard the Origin check leans on) and a foreign Origin, validates every check-in field before dereferencing it, wraps both async handlers so a throw is a 500 body rather than an unhandled rejection that would end the dev server, and answers every failure with a status and a body — 503 no-page-connected, 504 timeout — rather than holding the socket open. It holds one slot, so two tabs share it; each page reports a reporterId and connection.ambiguous says when more than one is live, because a documented limit beats a silently blended answer. Its own gate is plugins/__tests__/devToolbarAgent.test.ts (node --test, no dependencies) plus a playground typecheck, both wired into CI — examples/ is outside verify. plans/agent-readable-toolbar.md § Phase 3.

8. SSR

children render in a fragment, untouched, on the server. The bar is client-only: a mounted flag gates the portal, so the bar never appears in server HTML and there is nothing to hydrate and nothing to mismatch. <DevToolbarInset> holds its defaults (bottom, zero padding) until the same flag flips, so the server render and the first client render always agree.

Built entries carry a "use client" banner, so a React Server Components app can import them from a server component without a directive of its own.

The store's server snapshot is the resolved defaults

The mounted gate covers the bar and the inset, but not a consumer of its own: an app component calling useDevToolbar() renders on the server and again on the client, and what it reads has to agree across that boundary. The store is built by reading storage eagerly at construction, so if the same function served as both snapshots the server would see defaults and the first client render would see whatever the browser had persisted — a mismatch in the consumer's own output, not in anything core renders.

So useSyncExternalStore is given a separate getServerSnapshot, which returns the resolved defaults: defaultVisible ?? true, defaultPosition ?? "bottom", activePanelId: null, and defaultPanelHeight put through the same clamp as every other height. One object, built once at construction and never mutated, so its identity is stable across calls. Persisted values arrive on the render immediately after hydration, exactly as they do for the bar itself.

The consequence is deliberate, and it is the reason not to reach for the obvious workaround: an SSR-side storage adapter is unsupported. Preferences handed to storage on the server are ignored for the server snapshot and then appear on the client, which is precisely the mismatch this prevents. To differ from the built-in defaults on both sides, pass defaultVisible, defaultPosition or defaultPanelHeight — those are honoured by the server snapshot and by the storage fallbacks alike, because both resolve in one place.

Note this is a hydration concern, not a client-only one: getServerSnapshot is called only when hydrating. An app mounted with createRoot never reaches it, so a purely client-rendered toolbar reads persisted state on its very first render, with no frame of defaults in between.

The banner's one sharp edge

That claim is about importing and rendering. It is not about calling, and the difference is a build error rather than a runtime one.

"use client" makes each built entry a client module in its entirety. RSC permits a server component to render a client component, and forbids it from invoking a function exported by a client module — so a layout.tsx with no directive can render <DevToolbar>, and the moment it also writes metrics() the build fails with "Attempted to call metrics() from the server but metrics is on the client." Every first-party extension is a factory, so every RSC consumer meets this on their first attempt.

The fix is one small client module holding the extension array, which is where it belongs anyway, because §3 requires the objects to be built once outside render. The README shows the wrapper.

Verified in Next 16 app router, in next dev and in a production next build && next start with reactStrictMode: true: the server HTML contains the page and not one data-dev-toolbar or data-dtb-part, the bar mounts client-side, and the console carries no hydration warning.

9. What is deliberately absent

Not in coreWhere it goes
Event bus, ring buffers, throttled store, redact(), redactProse(), redactText() and describeError()./runtime
Metrics, environment, flags, overlays, diagnostics, theme editor./ext/*
Any design system, colour model or palette generator./ext/theme-editor edits the tokens you publish — core has no ctx and neither does it
Environment, build and session context, and any redaction of it./ext/environment — core has no ctx to hand anybody
A command palette UI./ext/command-menu — core aggregates and renders nothing
Any rendering of the diagnostics aggregation./ext/diagnostics — core collects the roster and renders nothing, as with commands
Severity thresholdsThe extension that owns the metric
Access controlThe consumer, before rendering <DevToolbar> at all
Error capture (window.onerror)./ext/diagnostics — a bounded, grouped tail of window errors, unhandled rejections and console.error/console.warn, folded into the snapshot. Core captures nothing; the patch restores by identity on teardown and is console: false-able. Your own reporter still belongs there as a sources entry.
A global extension registryNowhere. See §2.
Visual overlays over the host page./ext/overlays — core draws on nobody's application

10. Known gaps in the contract

Recorded rather than fixed, in descending order of how likely they are to bite. None is blocking; all were found by building an extension against the contract.

  • contractVersion is declarative and core cannot refuse. It warns, once, and then renders and starts the extension anyway. An extension declaring 2 against a core implementing 1 gets everything a matching one gets. That is deliberate — core has no basis to decide what a mismatch means, and refusing to render would turn a warning into an outage — but it means the field is documentation rather than a gate. The policy question that follows from this is ADR-003, and it is open.
  • An extension that needs to state the contract version must hand-maintain a copy of core's constant, because §7 forbids importing a value from core. src/ext/diagnostics/runtime.ts keeps TARGET_CONTRACT_VERSION for exactly this reason, and the number is printed into every outbound bug report, so drift is a wrong fact in somebody's ticket. Tests are not subject to the no-values rule, so a one-line equality assertion against CONTRACT_VERSION closes it. Copy the assertion, not just the constant.
  • ExtensionDiagnostics.data crosses core unredacted. Core cannot import /runtime (§2), so it cannot redact, and the reader does it instead. This is the right layering — the alternative inverts the architecture — but it means the aggregation itself is not a safe surface: a second reader that forgot to redact would ship raw contributions. This is why /ext/diagnostics redacts everything again even though the first-party contributors already have.
  • There is no invalidation signal for commands or diagnostics. Both are pulled, never pushed. It costs nothing today because capture is on demand, but a reader that wanted to watch an aggregation would have to poll.
  • ExtensionDiagnostics splits error and errorName on purpose, so each half can be masked before anything joins them. A reader that renders only error shows messages with no name in front of them; joining them is one template literal, after masking both halves — which is what /runtime's formatError() does for a thrown value (describeError() returns the halves; formatError() joins them). Now that a prose masker exists (redactProse(), below), the split is no longer needed for the embedded-URL case alone: a reader that masked the joined "Error: https://x/?token=…" would still find the URL. It is still needed for everything else redact() catches only as a whole value — describeError(new Error("Bearer secret")) masks the message, redactProse("Error: Bearer secret") does not — so preserving whole-value credential detection still requires masking the parts before joining. The halves are values — a name is a class identifier or a credential, never prose — and a value is what the anchored redact() judges exactly, where a prose sweep can only look for URLs inside it.
  • redact()'s value pass only inspects bare absolute URLs (scheme://…) — a string that is one, not one that contains one. Free text on its way out of the page (an error message, an axe failure summary, a console line) is not a value, so the reader runs redactProse() on it: the same whole-value pass, then every scheme://… run inside the text through redactUrl(). /ext/a11y (selectors, summaries, rule ids, help text, a thrown reason), /ext/diagnostics (console messages and names, roster errors, long-task attribution) and /ext/agent (a command's throw) all use it; /ext/metrics' network error column keeps a private URL-only scanner whose match stops at a closing delimiter, because its sentences and quote-glued URL pairs need that (runtime.md). A relative reference (/cb?access_token=…) has no scheme, so neither redact() nor redactProse() rewrites it; an absolute URL embedded in a header value is rewritten by redactProse() but not by redact(), whose value pass judges the whole header as one value. Either way, a caller that knows a value is a URL runs redactUrl() — or redactText() with url: true, which tries the whole value as a URL, relative references included, and scans for embedded scheme://… runs as well — on it explicitly. The environment extension does this for location.href and document.referrer, but a custom diagnostics source that dumps a relative route or a header bag must do the same for those fields. And redactProse() is defence in depth, not a guarantee: a credential in prose with no URL and no assignment syntax (Authorization: hunter2) is not found — see runtime.md.

Three hardening gaps of the form a hostile host global makes a guarded path fail are known and deliberately unfixed, because each costs more than it buys: createThrottledStore's write() calls its injected schedule unguarded; the console.error on a diagnostics failure path logs the raw thrown value (the developer's own console, not the outbound document — but worth knowing before pasting a console transcript into a ticket); and renderJson / fence describe a serialisation failure with the engine's own message.

Released under the MIT License.