Hint mode — DOM-injected overlay labels
Phase 3 of PLAN.md ships Vimium-style follow-by-letter-label hints: press f
to enter hint mode, type a few letters, the matched element gets clicked. F is
the background-tab variant — single-tab buffr falls back to a same-tab click
with a tracing::warn! breadcrumb until the tab strip lands.
Architecture: DOM injection
Hints render as real <div class="buffr-hint-overlay"> elements appended to the
page DOM. The host injects crates/buffr-core/assets/hint.js via
cef::Frame::execute_java_script after substituting three placeholders
(__ALPHABET__, __LABELS__, __SELECTORS__). The JS enumerates visible
matching elements, assigns sequential data-buffr-hint-id attributes, and
renders an overlay div per target.
This sidesteps the cross-process compositor work the OSR + wgpu path would have
required. docs/ui-stack.md records that compositing overlays on top of CEF's
surface is the trigger to migrate the chrome layer to OSR; we deferred that by
punting the rendering into the page itself instead.
IPC: console-log scraping (chosen)
CEF -> Rust uses the console-log fallback path, not cef_process_message_t.
The injected JS calls
console.log("__buffr_hint__:" + JSON.stringify(payload))
and BuffrDisplayHandler::on_console_message (in
crates/buffr-core/src/handlers.rs) pattern-matches the sentinel, parses the
JSON tail with serde_json, and writes into a one-slot HintEventSink
(Arc<Mutex<Option<HintConsoleEvent>>>). The host drains the sink each tick
from BrowserHost::pump_hint_events.
The cleaner cef_process_message_t IPC channel was rejected for v1 because it
requires a renderer-side RenderProcessHandler registered via
CefApp::on_render_process_handler, plus a V8 binding so JS can call
frame->SendProcessMessage(PID_BROWSER, msg). That's helper-subprocess plumbing
for a single one-way "hint list" message. Console-log scraping reuses the
display handler we already wired and works identically end-to-end. If the hint
list ever needs to flow at animation rates (live scroll-position updates), we'll
revisit.
Rust -> CEF stays on execute_java_script: the host calls
window.__buffrHintFilter(typed), __buffrHintCommit(id), or
__buffrHintCancel() from BrowserHost::feed_hint_key / backspace_hint /
cancel_hint.
JS surface
The injected script exposes three globals on window:
__buffrHintFilter(typed)— dim every overlay whose label doesn't start withtyped.__buffrHintCommit(elementId)— focus + click the element with the matchingdata-buffr-hint-target-id, then call__buffrHintCancel()to clean up.__buffrHintCancel()— remove every injected overlay div, strip everydata-buffr-hint-target-idattribute, and null out the three globals.
CSS
Every overlay carries the class buffr-hint-overlay. The injected
<style id="buffr-hint-style"> tag pins:
position: fixedz-index: 2147483647(HINT_OVERLAY_Z_INDEX, max int32 — page stacking contexts can't shadow the hints)- vivid yellow background (
#FFD83A), dark text, monospace 11px pointer-events: noneso the page below stays interactive- additional
buffr-hint-typed(dimmed) andbuffr-hint-hidden(display:none) classes the filter callback toggles
Label algorithm
HintAlphabet::labels_for(count) is a port of Vimium's hud.js BFS:
- Empty-string seed in a queue, walked breadth-first.
- Each pop expands by every alphabet char (prepended).
- Stop once the unexpanded slice (
queue[offset..]) holds enough. - Reverse each entry, then sort by alphabet position.
This guarantees uniqueness, no-prefix-collisions, and that the first N enumerated elements get the shortest labels.
Config
[hint] alphabet = "asdfghjkl;weruio" controls the character set. Validation
rejects empty / non-ASCII / duplicate inputs at config-load time so the runtime
path never has to handle them.