ADR-0009: Browser find-in-page via the native <webview> API in an independent pane
Status: Active · Date: 2026-07-19
Context
Tranquil is browser-first (ADR-0002) and a browser tab is an Electron <webview> (ADR-0005). Pressing ⌘F on one did nothing
useful: when the guest page has focus it swallows the keystroke, and Pulsar’s find-and-replace only
searches a TextEditor buffer — a live web page has no buffer, so its ⌘F panel is inert on a
browser tab. find-and-replace is third-party Pulsar core and is never modified; its only
extension points require a real TextEditor, and its provided service is buffer-marker-only.
Electron’s <webview> element, however, exposes a native find directly on the host side — findInPage(text, opts), stopFindInPage(action), and the found-in-page event — which highlights
in the live page with no guest injection, no IPC, and no @electron/remote. The work was to build a
real browser find on top of that and route ⌘F to it, which surfaced several platform frictions worth
recording (URI-scheme ownership, a native menu-accelerator leak, Electron’s findNext contract, and
how Pulsar hides inactive pane items).
Decision
Implement browser find as an owned feature in tranquil-browser: an independent, draggable
workspace pane item (BrowserFindItem) that drives the active tab’s native <webview>.findInPage(),
opens in the right dock, and is styled from the business design tokens — and route ⌘F to it while
suppressing the find-and-replace panel that leaks onto browser tabs. Concretely:
- Pane item, not a fixed bar —
BrowserFindItemexposesgetElement()(plain DOM, no space-pen),getURI() → "tranquil-find://find",getDefaultLocation() → "right",getAllowedLocations() → [bottom, center, left, right], and a search icon. It’s registered via an opener + singleton;showFind()opens it in the right dock (or focuses the existing one). It can be dragged to any pane/dock. - Non-
tranquil-browser:URI scheme — the browser’s own URL opener claims any URI starting withtranquil-browser:, so the find item usestranquil-find://to avoid being turned into a blank browser tab. - ⌘F routing — a workspace-level
tranquil-browser:findkeymap wins overfind-and-replace’s less-specific.platform-darwinbinding and callsabortKeyBinding()for non-browser items (so editor find still works); the webviewkeyHandlerhandles the guest-focused case (a focused page swallows ⌘F, forwarded over the existing key relay). - Kill the ⌘F menu-accelerator leak — Electron menu accelerators fire even when a
<webview>has focus, so the “Find in Buffer” item still dispatchedfind-and-replace:showonto a browser tab. AnonDidDispatchinterceptor hides that panel on browser tabs;find-and-replaceis eagerly activated at startup so the first ⌘F’s show + our hide happen in the same tick (no flash). findInPage(findNext: true)always — this selects/advances the active match and reports the count;findNext: falseonly highlights without selecting, so the counter never updates. Electron treats a changed query as a new search and an unchanged one as “advance”, so one flag covers first find and stepping (⌘G / ⇧⌘G).- Design-token styling — the active UI theme (
tranquil-business-*) does not remap Atom@ui-variables(those carry default Atom grays), so the find pane’s colors live in the business themes’tranquil-browser.lessusing the design tokens (@app-bg,@border-strong,@accent,@text-*). The terminal’s find palette is repainted the same way (it used@tool-panel-background-color→ default light gray). - Stale-highlight clearing on tab change — see Options; cleared pre-switch via a capture-phase
tab-bar
mousedown, with an on-arrival fallback.
Options considered
- Search engine — native
<webview>.findInPage(chosen): highlights the live page directly, host- side, no injection. Cons: bound to Electron’s webview find semantics and its “attached + dom-ready” requirement. Rejected: reusefind-and-replacevia itsgetEmbeddedTextEditorhook — needs a realTextEditorbuffer, so it can’t search or highlight a live DOM; modifyfind-and-replace— it’s unmodifiable core. - Surface — independent draggable pane item in the right dock (chosen): fits the workspace, movable,
and keeps the browser fully visible while searching. Rejected:
addBottomPanelstrip (didn’t fit the theme, sat below the cmd+j dock, non-native); a sibling tab in the browser’s own pane (a pane shows one tab at a time, so it hid — and thereby detached — the very<webview>being searched, which also threw “WebView must be attached to the DOM”). - Suppressing editor find on ⌘F — workspace keymap +
abortKeyBinding+onDidDispatchinterceptor + eager activation (chosen): browser tabs get our find, editors keep theirs, no third-party edits. Rejected: unbindingfind-and-replace’s ⌘F (breaks editor find and its menu accelerator still fires). - Clearing highlights on tab switch — pre-switch tab
mousedown+ arrival fallback (chosen): Pulsar hides inactive pane items withdisplay:none, and Electron refusesfindInPage/stopFindInPageon a hidden webview; nowill-change-active-pane-itemevent exists, so the only moment the outgoing tab is still clearable is the tab’smousedown(fires before the pane switches). Rejected: clear ononDidChangeActivePaneItem(fires after the tab is already hidden → can only clear on arrival → a one-frame flash); persist highlights per-tab like Chrome (no flash, but leaves stale highlights — the original complaint).
Consequences
- Easier: ⌘F, ⌘G/⇧⌘G, and a clickable search icon give a real find on any web page, highlighting in the live DOM; the pane is draggable and matches the business themes in light and dark; the terminal find palette is themed by the same tokens.
- Constraint — the hidden-webview clear. Because inactive tabs are
display:noneand un-clearable and no pre-switch Pulsar event exists, tab-click switches are flicker-free (mousedown pre-clear) but keyboard switches (Ctrl+Tab) still show a one-frame flash before the arrival-clear. The re-evaluation trigger is Pulsar gaining a will-change-active-item hook (would allow a clean universal pre-clear). - Risk — Electron webview find semantics. The always-
findNext:truebehavior and the “attached + dom-ready” requirement are Electron-version-dependent; an Electron upgrade that changes them is a re-evaluation trigger. - Coupling to
find-and-replaceinternals (menu-accelerator leak, lazy activation, panel class): the suppression is defensive and self-contained, but afind-and-replacechange to its command set or panel markup would need the interceptor revisited. If it ever exposes a pluggable search provider, the two finds could be reunified. - Minor: the find URI must stay off the
tranquil-browser:scheme; the tab-mousedownpre-clear is a DOM-level heuristic on the tab bar rather than a modeled event.
Validation
Verified live in a running window: ⌘F on a browser tab opens the Find pane in the right dock (opening
the dock if closed); typing a term + Enter highlights all matches in the page and shows “1 of N”; ⌘G /
⇧⌘G step through matches; the clickable search icon runs the find; the pane drags to other
panes/docks; the palette lists Tranquil Browser: Find (and toggle-url-bar / go-back / go-forward now appear regardless of focus); the first ⌘F after a reload no longer flashes Pulsar’s
editor find bar; the terminal find palette reads correctly in tranquil-business-dark and -light;
and switching browser tabs by click clears the previous tab’s highlight with no flicker. Implementation
lives in tranquil-browser (lib/browser-find-item.js, lib/tranquil-browser.js, keymaps, styles),
with themed colors in tranquil-business-dark/-light.