# 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](../src/platform.ts). It is attached (via `focusin`, which bubbles) to: - [src/ui/modal.ts](../src/ui/modal.ts) line 31 — main modal `contentEl` - [src/settings/tab.ts](../src/settings/tab.ts) line 69 — settings `containerEl` - [src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts) line 29 — passphrase modal `contentEl` (added this session) - [src/insert.ts](../src/insert.ts) line 121 — `RenamePromptModal` `contentEl` (added this session) The helper early-returns unless `Platform.isMobile`. ## Chronology of attempts ### Attempt 0 — pre-existing baseline (before this investigation) - **Mechanism:** `focusin` → `setTimeout(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: 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/platform.ts), [src/ui/modal.ts](../src/ui/modal.ts), [src/settings/tab.ts](../src/settings/tab.ts), [src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts), and [src/insert.ts](../src/insert.ts). Added a CSS rule in [styles.css](../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 `
` ([src/ui/passphrase-modal.ts](../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](../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](../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. ## Recommended next step: instrument before fixing Stop guessing. Add a temporary on-screen diagnostic (a `Notice`, or a fixed debug `
`) 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](../src/platform.ts) `installMobileKeyboardScrollFix` / `liftAboveKeyboard` - Call sites: [src/ui/modal.ts](../src/ui/modal.ts):31, [src/settings/tab.ts](../src/settings/tab.ts):69, [src/ui/passphrase-modal.ts](../src/ui/passphrase-modal.ts):29, [src/insert.ts](../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](../CLAUDE.md) (search "Mobile keyboard scroll").