wiseguru_ReWrite-Voice-Notes/docs/MOBILE_KEYBOARD_BUG.md
2026-05-28 12:28:01 -07:00

20 KiB

Mobile keyboard covers plugin text boxes (Android) — investigation log

Status: CSS top-anchor confirmed working; residual tall-popup cases fixed in Attempt 4b, awaiting device confirmation. After three JS approaches (all keyed off visualViewport) produced no observable change, the JS helper was removed entirely and replaced with a CSS-only top-anchor (see Attempt 4 below). The reporter confirmed the top-anchor fixed the simple popups; two tall-popup cases (change-passphrase fields, Paste submit button) were then addressed in Attempt 4b. Corroborating evidence drove the pivot: (a) the reporter confirmed another plugin with the same popup-style text boxes hits the identical bug, establishing this as Obsidian-mobile platform behavior rather than a defect in our code; (b) the official Obsidian docs expose no plugin-facing keyboard/viewport/inset API and confirm Obsidian mobile is Capacitor-based (Platform.isMobileApp = "running the capacitor-js mobile app"), which is consistent with Hypothesis A (keyboard overlays without resizing the viewport, so no JS signal fires). The CSS approach is signal-independent: it does not depend on visualViewport or any Capacitor event. This document records everything tried so the history is not lost.

Problem statement

On Android, when a text field managed by the plugin receives focus, the on-screen software keyboard slides up and covers the text field. The field does not move out of the way, so the user cannot see what they are typing. The expected behavior (seen in Obsidian core UIs and in other plugins) is that the focused field scrolls/repositions above the keyboard.

Reporter platform: Android, Obsidian mobile. Desktop and iOS are not affected (the helper early-returns on !Platform.isMobile, and iOS WebKit resizes the layout viewport when the keyboard opens, which makes naive scrollIntoView work there).

Test matrix

The reporter exercises these flows, positioning each field low enough that the keyboard would overlap it:

# Flow Surface Container Last reported result
1 Quick controls (bottom-right hamburger) → ReWrite → tap Unlock Passphrase unlock prompt PassphraseModal (popup) FAIL
2 Quick controls → ReWrite → Paste → tap textarea Paste tab textarea ReWriteModal (popup) FAIL
3 Sidebar → Settings gear → ReWrite (Voice Notes) → scroll to Change passphrase → tap both fields Change-passphrase prompt PassphraseModal (popup, requireConfirm) FAIL
4 Sidebar → Settings gear → ReWrite (Voice Notes) → tap a field (transcription API key, assistant name, attachments folder, etc.) Settings tab field ReWriteSettingTab (settings panel) PASS (confirmed still passing)

Disambiguation (confirmed by reporter): test 4 still succeeds; tests 1-3 still fail. So the split is stable: scrollable settings surface works, fixed-position popups do not.

Critical caveat — this does NOT prove our code runs. Test 4 passing is most likely native browser / Obsidian-core behavior, not our helper. Chrome on Android natively scrolls a focused input into view within its nearest scrollable ancestor, and is keyboard-aware when doing so. The settings panel is exactly that: a tall scrollable surface. A fixed-position centered popup is not, so native focus-scroll cannot move it and the field stays covered. In other words, the test 4 / tests 1-3 split is fully explained by the browser, with our helper contributing nothing in any of the four cases. Hypothesis A (our code is a silent no-op because visualViewport never changes) remains fully alive. We still have zero positive evidence that installMobileKeyboardScrollFix mutates anything on the device.

The reporter confirmed the build was deployed cleanly: they deleted the plugin's main.js, manifest.json, and styles.css and recreated them (keeping data.json and secrets.json.nosync), so stale-artifact caching is ruled out.

The code under test

All attempts live in one helper: installMobileKeyboardScrollFix(root) in src/platform.ts. It is attached (via focusin, which bubbles) to:

The helper early-returns unless Platform.isMobile.

Chronology of attempts

Attempt 0 — pre-existing baseline (before this investigation)

  • Mechanism: focusinsetTimeout(300ms)target.scrollIntoView({ block: 'center', behavior: 'smooth' }).
  • Wired into: main modal + settings tab only.
  • Theory: scroll the focused input to the center of its scroll container after the keyboard animation starts.
  • Why it can't work on Android: scrollIntoView({ block: 'center' }) centers the element in the layout viewport (the full screen). Android (unlike iOS) does not shrink the layout viewport when the keyboard opens, so "center of the full screen" is still behind the keyboard's top edge for low fields.

Attempt 1 — visualViewport + scroll nearest scrollable ancestor

  • Change: Rewrote the helper to read window.visualViewport. On focusin: if the visual viewport is already shrunk act immediately, else wait for a one-shot visualViewport resize (600 ms safety timeout). Then compute visibleBottom = vv.offsetTop + vv.height, and if the input's rect.bottom is below visibleBottom - 16, scroll the nearest scrollable ancestor by the delta (fallback window.scrollBy). Kept a scrollIntoView fallback for when visualViewport is unavailable.
  • Also: wired the helper into PassphraseModal and RenamePromptModal.
  • Theory: visualViewport reflects the real post-keyboard visible region on Android even though the layout viewport does not, so we can scroll precisely.
  • Result: Reporter: test 4 (settings) works; tests 1-3 (popups) fail.

Attempt 2 — translate the .modal box via CSS transform

  • Change: Added a branch: when no scrollable ancestor is found, walk up to the .modal element and apply a composed transform: <computed> translateY(-delta) to lift the modal box, restoring on blur. Composition read the current computed transform so as not to wipe Obsidian's centering transform.
  • Theory: short popups have no scroll room, so the scroll path did nothing; lift the whole fixed-position modal instead.
  • Result: Reporter: still broken for tests 1-3.

Attempt 3 — shrink .modal-container so flex re-centers the popup

  • Change (current code): Two-step in liftAboveKeyboard: (1) scroll the nearest scrollable ancestor and re-measure; (2) if the input is still below the visible region, walk up to .modal-container and set inline top = vv.offsetTop and height = vv.height, shrinking the fixed-position container to the visible region so Obsidian's flex centering re-positions .modal above the keyboard. Restore on blur; re-fit on subsequent visualViewport resize; fully restore when vv.height returns to near window.innerHeight.
  • Theory: .modal-content actually has overflow: auto, so Attempt 2's scroll path was being taken and the transform branch never ran. Scrolling inside .modal-content shifts the input within the modal but cannot move the fixed, centered modal itself; shrinking the container makes the existing flex centering do the work.
  • Result: Reporter: "No change for any of the tests."

Attempt 4 — CSS-only top-anchor; JS helper removed (current)

  • Change: Deleted installMobileKeyboardScrollFix and its four call sites from src/platform.ts, src/ui/modal.ts, src/settings/tab.ts, src/ui/passphrase-modal.ts, and src/insert.ts. Added a CSS rule in styles.css: under .is-mobile, .rewrite-modal (main + passphrase) and .rewrite-rename-modal get align-self: flex-start; margin-top: 8px; margin-bottom: auto; max-height: calc(100% - 16px).
  • Theory: This is alternative #3b made concrete. The one surface that always worked (the settings tab) is a tall scrollable container where Chromium's native keyboard-aware focus-scroll does the job; the failing surfaces are short fixed-position popups centered by Obsidian's flex. Rather than fight the keyboard with JS that never had a signal to fire on, pin the popup to the top of the screen so the keyboard (which opens from the bottom) cannot cover it. The reporter confirmed other plugins use exactly this workaround.
  • Why CSS over JS: Hypothesis A (the keyboard overlays without resizing the viewport) means no JS signal is reliable. CSS positioning needs no signal. align-self: flex-start moves only our modal to the top of the flex container; margin-bottom: auto is a fallback for non-default flex configurations; scoping to our classes leaves core Obsidian modals untouched.
  • Caveat for the main modal: the Paste textarea sits below the template selector and tab row, so top-anchoring lifts it into the upper half of the screen but not to the very top. For the passphrase and rename modals the input is the first field, so they clear fully.
  • Result: Confirmed working on device for the simple popups (unlock prompt, rename prompt, Paste textarea itself). Two residual cases remained, both instances of "something pushes the focused element low within a tall popup," fixed in Attempt 4b below.

Attempt 4b — companion tweaks for tall-popup cases (current)

Top-anchoring fixes popups whose input sits near the top, but two surfaces kept the focused element low:

  1. Change passphrase (settings → Change passphrase): the two passphrase fields render below the "Picking a strong passphrase" tips block, so even a top-anchored modal put them in the keyboard zone. Fix: the tips block is now a <details> (src/ui/passphrase-modal.ts renderPassphraseTips), expanded by default on every platform (opt-out security guidance) but auto-collapsing on mobile when a passphrase field is focused (collapseTipsOnMobile), so the guidance is seen on open yet the fields rise under the title once the user taps in. The first field's autofocus is disabled on mobile so the collapse coincides with the user's tap, not a premature programmatic focus.
  2. Paste tab submit button: the textarea was fine but the "Clean up" button below it was covered. Fixes: the textarea renders at rows = 4 on mobile (vs 10 on desktop, src/ui/modal.ts) with the desktop 160px min-height floor dropped to 80px under .is-mobile; and the reporter's observation that Obsidian leaves an empty band above the title is addressed by padding-top: 8px on .modal-content + margin-top: 0 on the h2 (mobile-scoped), shifting the whole popup up.

All of these are in styles.css plus the two small JS changes noted above. Still signal-independent; no visualViewport or Capacitor dependency.

Removed JS helper (Attempts 1-3, verbatim summary — no longer in the codebase)

This describes the installMobileKeyboardScrollFix / liftAboveKeyboard logic that Attempt 4 deleted. Retained for history only; none of this runs anymore.

liftAboveKeyboard(target, vv):

  1. visibleBottom = vv.offsetTop + vv.height. If rect.bottom <= visibleBottom - 16, return (do nothing) — we believe the input is already visible.
  2. Scroll nearest scrollable ancestor by delta; re-measure; return if now visible.
  3. Else shrink .modal-container (top/height), restore on blur.
  4. Else window.scrollBy.

The entry path waits for visualViewport.resize (or acts immediately if vv.height < window.innerHeight - 100), with a 600 ms fallback timer.

Unverified assumptions (the heart of the problem)

We have been writing code against a mental model that has never been confirmed on the actual device. Every one of these is a guess:

  1. That the focusin listener fires at all. Never confirmed. If Platform.isMobile is somehow false, or the listener is attached to the wrong element, nothing runs.
  2. That window.visualViewport shrinks when the Android keyboard opens. This is the load-bearing assumption for all three attempts, and it is the most suspect (see hypothesis A).
  3. That window.innerHeight does / doesn't change when the keyboard opens (drives the "already up" check and the restore condition).
  4. The DOM structure and class names of Obsidian mobile modals (.modal-container > .modal > .modal-content), and that .modal-content has overflow: auto, and that .modal-container centers via flex. Never inspected on device.
  5. That step 1's early-return condition is ever false on the device. If visualViewport does not shrink, rect.bottom <= visibleBottom - 16 is always true and the function is a guaranteed no-op — which would perfectly explain "no change for any approach."
  6. Whether test 4 "working" is our code or Obsidian's own behavior. If Obsidian core scrolls its own settings panel on focus, test 4 would "pass" regardless of our helper, and we have zero evidence our code does anything anywhere.

Leading hypotheses (ranked)

A. visualViewport does not reflect the keyboard in Obsidian's WebView (most likely)

Obsidian mobile is a Capacitor app. The Capacitor Keyboard plugin has a resize mode (native / body / ionic / none). If Obsidian uses resize: 'none' (keyboard overlays content), then neither window.innerHeight nor window.visualViewport.height changes when the keyboard opens — the keyboard simply floats on top. In that case:

  • The entry "already up" check (vv.height < innerHeight - 100) is never true.
  • The visualViewport resize event may never fire, so we fall to the 600 ms timer.
  • When liftAboveKeyboard finally runs, visibleBottom equals the full-screen bottom, so rect.bottom <= visibleBottom - 16 is true and we return immediately, doing nothing.

This single hypothesis explains why three completely different mutation strategies all produced no visible change: none of them ever executed their mutation. This is the first thing to verify.

If true, the correct signal is the Capacitor keyboard event, not visualViewport. Capacitor dispatches DOM events on window: keyboardWillShow / keyboardDidShow (with event.keyboardHeight) and keyboardWillHide / keyboardDidHide. Obsidian is known to surface these. The fix would key off keyboardHeight instead of visualViewport.

B. The listener never fires / wrong element

Less likely given the helper is attached at four call sites, but unproven. Would also explain "no change anywhere."

C. visualViewport works, but our DOM model is wrong

If visualViewport does shrink (so step 1 does not early-return) but .modal-container is not the real class, or it does not center via flex, or top/height are overridden by !important rules, then step 3 silently fails. This is plausible only if hypothesis A is false.

Stop guessing. Add a temporary on-screen diagnostic (a Notice, or a fixed debug <div>) that fires on focusin and again ~400 ms later, dumping the real numbers. Because remote DevTools on Android Obsidian is fiddly, an on-screen readout is the lowest-friction way to get ground truth.

Capture, at focus time and after a delay:

  • Platform.isMobile
  • window.innerHeight before vs. after keyboard
  • window.visualViewport?.height, .offsetTop, .pageTop before vs. after
  • whether a visualViewport resize event fired (counter)
  • whether any keyboardDidShow window event fired, and its keyboardHeight if present
  • the focused element's getBoundingClientRect().bottom
  • the chain of ancestor class names from the input up to body (to confirm .modal-container / .modal / .modal-content and which one, if any, is scrollable)

The single most important data point: does visualViewport.height (or innerHeight) actually decrease when the keyboard appears? If no, hypothesis A is confirmed and we pivot to Capacitor keyboard events. If yes, hypothesis A is dead and we debug the DOM model (hypothesis C).

If feasible, also enable remote debugging: connect the Android device over USB, open chrome://inspect in desktop Chrome, inspect the Obsidian WebView, and watch the DOM/visualViewport live while focusing a field. This is the gold-standard confirmation.

Alternative fix approaches not yet tried

Pursue these only after instrumentation tells us which signal is real.

  1. Capacitor keyboard events (most promising if hypothesis A holds). Listen on window for keyboardWillShow / keyboardDidShow and read event.keyboardHeight. Apply a bottom inset (e.g. padding-bottom on the scrollable region, or shrink .modal-container by keyboardHeight) and reverse it on keyboardWillHide / keyboardDidHide. This does not depend on visualViewport updating.
  2. CSS environment inset. Newer WebViews expose env(keyboard-inset-height) / env(keyboard-inset-bottom) when the page opts in via the interactive-widget viewport meta — but a plugin cannot set the viewport meta, so this likely requires Obsidian-level support. Worth testing whether the env vars are non-zero on the device anyway.
  3. Pure CSS positioning of popups. Anchor .rewrite-modal / .rewrite-passphrase-modal to the top of the screen on mobile (e.g. .is-mobile .modal { top: 8px; transform: none; }) so even a non-moving modal sits above where the keyboard appears. Crude, but independent of any JS signal, and a useful diagnostic: if a top-anchored modal is still covered, the keyboard is taller than assumed or our CSS is being overridden. 3b. Make popups behave like the settings surface (highest-leverage, given the confirmed split). The one thing that demonstrably works is a tall scrollable container (settings tab) where native keyboard-aware focus-scroll does the job. Mimic that for popups: on mobile, top-anchor the popup and give its content a scrollable region that can extend below the fold (.is-mobile .rewrite-modal { top: 0; transform: none; max-height: 100%; } plus an overflow-y: auto content area with enough height that the focused field can scroll up). If the browser then scrolls popup fields into view the same way it does settings fields, the bug is fixed without relying on visualViewport or Capacitor events at all. This is CSS-first and signal-independent, and it directly transplants the working case onto the failing cases — try it before the JS-signal approaches.
  4. Ask whether Obsidian already exposes a hook. Check Obsidian's mobile behavior and any documented API for keyboard insets before reinventing it; mirror whatever the core settings panel does (since test 4 historically worked, the core mechanism is worth copying directly).
  5. Confirm the webspeech removal / unrelated churn isn't interfering. Unlikely, but the working tree has broad uncommitted changes; verify the helper is actually the code shipping in main.js (search the built bundle for visualViewport / modal-container).

Key references

  • Helper: src/platform.ts installMobileKeyboardScrollFix / liftAboveKeyboard
  • Call sites: src/ui/modal.ts:31, src/settings/tab.ts:69, src/ui/passphrase-modal.ts:29, src/insert.ts:121
  • Digital Garden plugin (referenced by the reporter as a working example): its Appearance modal is a bare new Modal(app) with no keyboard-handling code — its good behavior comes from Obsidian core, not plugin code. So "copy Digital Garden" reduces to "copy Obsidian core's modal behavior."
  • Gotcha entry summarizing the helper: CLAUDE.md (search "Mobile keyboard scroll").