@mithril-inspector/overlay
v0.3.2
Published
The in-page inspector UI for Mithril Inspector — element picker, highlight overlay and source tooltip.
Maintainers
Readme
@mithril-inspector/overlay
The in-page inspector UI for Mithril Inspector. It is
itself written in Mithril.js and mounts into an isolated shadow root, so it adds
no global styles and cannot clash with the host application's CSS. No Vite or
bundler dependencies (ADR-004): it depends only on @mithril-inspector/protocol
(types) and mithril, and consumes the runtime hook through the global
window.__MITHRIL_INSPECTOR__ rather than importing the runtime package.
Ships the source inspector: an unobtrusive collapsed toggle, an element picker, highlight rectangles, a source tooltip/badge, and a selected-component detail pane with Open in editor. The panel's look-and-feel follows the Vue DevTools convention: an "M" toggle docked to the bottom of the viewport when collapsed, and a full-width docked panel with a left icon sidebar when expanded.
Mounting
import { mountInspectorOverlay } from "@mithril-inspector/overlay"
const handle = mountInspectorOverlay({
theme: "system",
picker: { toggleShortcut: "Alt+Shift+M", continuous: false },
})
// handle.dispose() tears down listeners, unmounts Mithril, and removes the host.mountInspectorOverlay(options?, deps?) creates <div id="__mithril-inspector-host">
on document.body, attaches an (open by default) shadow root, injects the scoped
stylesheet, mounts the Mithril overlay, and calls hook.excludeHost(host) so the
overlay is excluded from element picking and runtime tracking. It returns
null when disabled or when there is no DOM (e.g. a production build with the
runtime stripped). deps lets you inject a hook, document and
window for testing.
Behavior
- Toggle & docked panel: collapsed, the overlay is a small "M"
toggle centered at the bottom of the viewport, faded to low opacity until
hovered. Hovering (or picking) reveals a second target/crosshair icon next
to it — a glowing halo appears — that starts picking directly without
opening the panel. Expanded, the overlay docks a panel to the bottom of the
viewport spanning the full width, with a left icon sidebar (Components, then
State History, then Settings at the bottom; click the "M" mark at the top of
the sidebar to collapse back to the toggle). Collapsed state, the active sidebar section,
and the component tree's search query all persist in
localStorage— deliberately, since a Vite dev-server WebSocket reconnect (e.g. after "Open in editor" backgrounds the browser tab long enough for it to drop) triggers a full page reload (Vite's own behavior, not this package's), which would otherwise silently reset the panel. - Picker: toggle with
Alt+Shift+M, momentary hold withAlt,Enteropens the current source,Escapecancels. Every shortcut is remappable (right in the Settings tab, or via options) and can be disabled ("none"). Picking never jumps to the editor by itself (picker.openOnClickdefaults tofalse) — a pick lands its result in the docked panel in place;picker.openEditorModifier(default Meta/Cmd/Win) jumps straight to the editor on click instead, andpicker.passThroughModifier(defaultAlt+Shift) lets one click through to the app underneath without leaving picking mode. - Hover & highlight: a capture-phase pointer listener uses
document.elementsFromPoint, ignores the overlay host, resolves the best source/component mapping through the hook, and draws separate fixed-position rectangles fromgetBoundingClientRect— never touching the target's styles. Pointer, scroll and resize work is coalesced to one update per animation frame. - Selection: clicking suppresses the application click by default
(hold
Meta/Cmd to pass it through), freezes the highlight, shows the details panel, and exits the picker unless continuous mode is on. - Stale nodes: a removed selection shows "Element no longer mounted"
and offers to reselect the nearest still-mounted ancestor. The selected node
and its ancestor chain are held via
WeakRef, so nothing is pinned in memory. - Mapping precision: the UI distinguishes an exact element source from an inferred fallback (component view → declaration → module) with a colored badge.
- Display names (task 0018): the hover badge and the details
panel's "Component" row show the same colored "Inferred" badge next to a
component name resolved via the display-name fallback tiers (filename-derived or
"Anonymous"), so a guessed name is never mistaken for an explicit or declared one. - Ancestry breadcrumb & source actions (task 0019): the
detail pane's breadcrumb shows the full root-first chain (
App › UserList › UserCard) for the selected element's owning component, each crumb its own resolved (and exact-vs-inferred marked) display name. Clicking a crumb (focusAncestor) highlights its own rendered DOM range — every top-level sibling node for a fragment-root component, not just the first — without changing what's selected; the toolbar's actions always stay scoped to the actually-selected component. The toolbar is one icon per action (the label is a tooltip,title/aria-label): Open in editor (the exact clicked element's own source, viaopenSelectedInEditor), then one icon per additional location that actually resolved — component view, component declaration — skipping a redundant "rendered element" icon (openSelectedInEditoralready covers that exact target), then pin, scroll into view, and clear. A component inside a hidden (markInspectorHidden) ancestor's subtree is excluded from the chain rather than leaving a gap. - Component tree (task 0022): the sidebar's Components
section shows the full Mithril component hierarchy — display names and
key="…"badges (e.g.UserCard key="42"), plain HTML elements excluded by default — seeded once fromhook.getSnapshot()and thereafter patched incrementally from batchedRuntimeEvents viahook.subscribe(no re-fetch, no full rebuild per redraw). Each row shows an update-count badge and, once at least one of its own renders exceeded the runtime's slow-render threshold inmode: "full"(default 16ms — one 60fps frame budget, task 0029), a⚠ Nwarning badge with the latest duration in its tooltip; the detail pane trails the same "(last render: Xms)" text (styled as a warning once slow) onto the breadcrumb row for the selected component. Both stay hidden entirely outsidemode: "full"— no gate message, since the tree's own badges already carry that signal. Rows can be expanded/collapsed (chevron, orArrowRight/ArrowLeft;ArrowUp/ArrowDownmove a rovingtabindex,Enter/Spaceselect — a flatrole="tree"witharia-levelper row rather than physically nestedrole="group"s). Selection is bidirectional: picking a DOM element (via the target icon next to the tree search, or the collapsed toggle's own picker icon) marks its nearest component selected in the tree, and selecting a tree row highlights that component's own DOM range and shows the same detail pane described above. A search box filters by display name, keeping every match's ancestors visible for context. Pinned components stay listed with a "not mounted" marker instead of disappearing once they unmount (ids are never reused, so that's the last state they'll ever report). The tree pane is gated bycomponentTree.enabled— the detail pane's breadcrumb/toolbar work regardless, since they only need a DOM selection/mapping, not tree tracking; attrs/state previews additionally requiremode: "full"andcomponentTree.captureAttrs/captureState— each shows an explanatory message instead of silently doing nothing when a prerequisite isn't met. Attrs/state render the lazy preview tree from the runtime's safe serializer directly as a key/value list (no redundant "Object" label at the root): containers show their already-fetched entries inline with a "Show more" page-forward button once truncated, a getter shows(...)until clicked (expandComponentPreview→ the hook'sexpandPreview, evaluated only on that explicit action), and a redacted value always renders its configured replacement text (default[redacted]) — the redaction itself happens in the runtime, never in this package. - State History (task 0027, refined task 0028): a read-only timeline of
the currently selected component's state preview, recorded each time it
redraws (driven by the same batched
RuntimeEvents as the Components tab — no separate polling), newest first. The tab reuses the same left tree pane the Components tab has — selecting a component there keeps the history in sync — plus its own "Watching:<name>" heading, so it's always clear whose state is being recorded. Selecting an entry shows a diff against its own immediate predecessor; a"changed"object/array field expands into an aligned two-column before/after table (row-per-key/index) instead of a bareArray(3) → Array(4), and an added/removed object/array field expands into a single fully-nested column — both non-interactive, static renderings of the frozen historical snapshot (no getter evaluation, no live round-trip, unlike the Components tab's own attrs/state preview). The selection auto-follows new snapshots as long as whatever was selected was itself the latest entry at the time; explicitly pinning an older entry keeps it pinned across future recordings. Gated identically to the Components tab's Attrs/State sections (mode: "full"+componentTree.captureState). Built entirely from data the runtime hook already exposes for any component'sstate— most useful pointed at a root/layout component that receives a Meiosiscell().stateas its own state, the closest read-only analog to meiosis-tracer's timeline this package offers. There is deliberately no rewind/replay: pushing a historical snapshot back into a live app is out of scope (time-travel debugging is an explicit non-goal), and unlike this package's other instrumentation, meiosis-tracer's own time-travel needs a live reference to the app's actual state stream handed to it — something no zero-app-code-change strategy this project uses can obtain on its own. - Redraw-flash visualization (task 0030): opt-in
(
redrawFlash.enabled, off by default) andmode: "full"-only — a brief highlight over a component's own DOM range when its DOM actually mutated this redraw, not merely when itsview()ran (every component'sview()runs on everym.redraw()regardless of whether anything changed; Mithril's own diff only touches real DOM where the new/old vnode trees actually differ, and that's the signal this keys off). Detection is a singleMutationObserverondocument.body(childList/attributes/characterData,subtree: true) — deliberately not scoped to individualm.mountroots, since observing one stable ancestor that contains every root needs no root-discovery bookkeeping and handles multiple independent roots and roots that mount/unmount later for free. Mutation records are rAF-throttled (createFrameScheduler, mirroring the existing pointer-move/highlight-refresh scheduling) and resolved to owning components via the sameresolveDomComponentthe picker uses, so the existingexcludeHostexclusion applies unchanged — reinforced by shadow DOM boundaries already being opaque to a light-DOMMutationObserver(the overlay's own UI lives entirely in its shadow root). Each flash fades over ~400ms and clears itself on a timer independent of whether it animates, soprefers-reduced-motionis satisfied by the existing global.mi-rootreduced-motion rule alone — no separate check needed. - Elements tab (task 0031): §9.1's own optional "expansion of a component
into its owned vnode/element tree" — the Components tab's tree deliberately
hides plain DOM elements, so this is a separate tab (reusing the same left
tree pane the Components/History tabs do — selecting a component anywhere
keeps this in sync) that recursively walks the selected component's own
domRangeand renders it as mithril hyperscript shorthand (div.scroll,span#counter-value) rather than a raw tag/attribute dump. A direct child component's own rendered range (found viadomRange.first, without descending into it) shows as a clickable name instead of its DOM, so nested components stay one click away in the Components tree rather than being shown twice. Every element row (and a standalone text row) is itself clickable, jumping straight to that exact node's nearest source — the sameresolveDomSourcelookup the picker uses — without going through the shared selection. An element's own direct text children render inline on its own row (e.g.h2 Attrs demo) rather than as separate nested rows, since an element typically either has more markup underneath it or some text content, rarely needing its own line for each — plain content renders trimmed and unquoted, while pure whitespace (a deliberate separator between two inline elements, otherwise invisible) renders quoted (" ") so its presence is still obvious. The walk is capped on both total node count and nesting depth, degrading to a truncation notice rather than rendering an unbounded subtree. Tag-name visibility (div.scrollvs. the tag-omitted.scroll, matching mithril's own selector shorthand) is a Settings-tab toggle (elementsPane.showTagName, on by default) — a pure display preference with nomode/capture gating, unlike attrs/state or redraw-flash. - Accessibility: semantic controls, ARIA roles (
dialog,status,tree,treeitem), visible focus indicators, WCAG AA contrast, reduced-motion support (also respected by "Scroll into view", viamatchMedia), a visible picker-active banner, and light/dark theming that followsprefers-color-schemeby default. - Resilience: every overlay operation runs inside an error boundary; failures are recorded and surfaced in the Settings section's diagnostics view, and never break the host application.
Public building blocks
mountInspectorOverlay is the entry point, but the composable pieces are also
exported for tooling and tests: createOverlayController, OverlayRoot (the
Mithril component), resolveOverlayOptions, getOverlayHook, describeMapping,
createPickerMachine, createSelectionModel, createFrameScheduler,
createEditorClient, parseShortcut, the persistence helpers, (task 0022)
createComponentTreeStore plus the preview-tree formatters summarizeNode,
isExpandable and pathKey, (task 0027/0028) createHistoryStore,
diffPreviewNodes, containerEntries and alignContainerEntries, (task 0030)
componentsWithMutatedDom — the pure DOM-mutation-to-component attribution
logic behind redraw-flash detection — and (task 0031) buildElementsTree,
buildChildBoundaries and formatElementLabel — the pure DOM-walk and
hyperscript-formatting logic behind the Elements tab.
Editor endpoint
Opening a source POSTs { file, line, column } (only — never component data)
to POST /__mithril-inspector/open-in-editor, served by
@mithril-inspector/server and wired up by the bundler adapter.
Notes on styling
mithril-materialized was evaluated for the panel but not used: it ships global
CSS, which conflicts with the shadow-root / no-global-styles constraint. The
overlay uses a self-contained, shadow-scoped stylesheet instead (styles.ts).
Known limitations
- Expanding a component into its owned vnode/element tree is not implemented. The tree always shows only the Mithril component hierarchy; there is no per-component toggle to reveal the plain DOM/vnode subtree it renders. Everything else (display names/keys, search, pinning, update counters, bidirectional selection sync, attrs/state previews, incremental batched updates) is implemented — this one sub-feature is deliberately deferred rather than silently dropped.
- Large trees are not virtualized (no windowing library). Collapsing a node does stop its subtree from being rendered at all, which is the cheap half of "virtualize or lazily render large trees"; true virtualization of a fully-expanded huge tree is a follow-up.
- Attrs/state pagination replaces the shown page rather than accumulating
it. Clicking "Show more" on a truncated container re-fetches and displays
entries
[offset, offset + maxEntries)(the runtime'sexpandPreviewcontract) instead of appending to what's already shown — simpler and still fully "paginated", but not an infinite-scroll accumulation. - The selected component itself does not survive a Vite full-reload (only
the active sidebar section and component tree search query do, both via
localStorage— see "Toggle & docked panel" above). AComponentIdis only valid for the page load that assigned it, so after a reload the previous selection can't be re-applied directly; re-resolving the nearest matching component by its old source location was considered and deferred as a larger, separate piece of work. - Highlight margins/padding visualization is deferred to a later release.
- Real-browser integration (playground, Chromium/Firefox/Safari) is exercised by the Vite playground and browser tests; this package is unit- and jsdom-tested in isolation.
- A native
<dialog>opened withshowModal()blocks the overlay entirely.showModal()promotes the dialog into the browser's top layer, which paints above the shadow-root host regardless of z-index, and makes the rest of the document inert — pointer and keyboard events aimed at the overlay never arrive while the dialog is open (confirmed empirically: a bare capture-phasekeydownlistener ondocumentreceives zero events, even thoughdocument.activeElementcorrectly sits inside the dialog). There is no supported way to intercept or work around this from outside the dialog's own subtree — a real fix would mean promoting the overlay itself into the top layer (Popover API), which only helps interacting with the overlay (not picking elements hidden behind the dialog anyway) and is out of scope for now. Instead,mountInspectorOverlaywatches for:modalvia aMutationObserveron theopenattribute and records a"modal-dialog"diagnostic (visible in the Settings section once the dialog closes, and viaconsole.warnindebugmode) so the silence is explained rather than silently swallowed. Ordinary custom "modal" UIs built from a plainposition: fixeddiv are unaffected — the overlay's host already always wins normal z-index stacking (z-index: 2147483000, last child ofdocument.body).
