Docs

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 barBrowserFindItem exposes getElement() (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 with tranquil-browser:, so the find item uses tranquil-find:// to avoid being turned into a blank browser tab.
  • ⌘F routing — a workspace-level tranquil-browser:find keymap wins over find-and-replace’s less-specific .platform-darwin binding and calls abortKeyBinding() for non-browser items (so editor find still works); the webview keyHandler handles 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 dispatched find-and-replace:show onto a browser tab. An onDidDispatch interceptor hides that panel on browser tabs; find-and-replace is 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: false only 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.less using 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: reuse find-and-replace via its getEmbeddedTextEditor hook — needs a real TextEditor buffer, so it can’t search or highlight a live DOM; modify find-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: addBottomPanel strip (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 + onDidDispatch interceptor + eager activation (chosen): browser tabs get our find, editors keep theirs, no third-party edits. Rejected: unbinding find-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 with display:none, and Electron refuses findInPage/ stopFindInPage on a hidden webview; no will-change-active-pane-item event exists, so the only moment the outgoing tab is still clearable is the tab’s mousedown (fires before the pane switches). Rejected: clear on onDidChangeActivePaneItem (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:none and 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:true behavior 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-replace internals (menu-accelerator leak, lazy activation, panel class): the suppression is defensive and self-contained, but a find-and-replace change 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-mousedown pre-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.