bug(frontend): Android Keplr token menu taps open the wallet URL bar #632

Closed
opened 2026-08-25 01:57:08 +00:00 by PlasticDigits · 14 comments
PlasticDigits commented 2026-08-25 01:57:08 +00:00 (Migrated from gitlab.com)

Summary

On Android Keplr in-app browser, tapping a token in the Swap You Pay / You Receive menu often activates the Keplr URL / navigation chrome instead of selecting the coin. Compact phones make this nearly 100% of “fast pick” attempts. Desktop and wide viewports are fine.

Community reports (2026-08, anonymized): multiple Android devices (compact and large) in Keplr’s in-app browser on https://dex.cl8y.com/. Opening the token menu with the IME up places the last list rows a few pixels above Keplr’s bottom URL bar; a quick tap lands on the bar (keyboard / tab switcher / overflow) rather than the option.

This is not a wallet-connect bug. Keplr in-app remains a supported path (WC-M7 / #554). The picker UI does not reserve space for in-app chrome or the visual viewport.

Related (do not regress): #481 (Swap combobox), #498 (mobile CLS), #181 (portal layout), #244 (listbox keyboard), #347 (mobile tab bar), #350 (typed Enter = first hit). Label leak for uluna/uusd is a separate picker issue (#630).


Current codebase

What users tap

Swap pay/receive use TokenSearchSelect — an <input type="text" role="combobox">. onFocus / onClick open a portaled <ul role="listbox"> on document.body. Same control is reused on Create Pair, pay-with-any-token, and Pool one-sided add/withdraw. Trade / Limits use the twin PairSearchSelect (also an input combobox). Mint keeps button-trigger TokenSelect.

Tapping the trigger focuses a text field, so Android Gboard opens even when the user only wants to pick a visible row. Viewport meta in frontend-dapp/index.html is width=device-width, initial-scale=1.0 with no interactive-widget. Chromium’s default is resizes-visual: window.innerHeight stays large; only visualViewport shrinks.

How the menu is placed

usePortalListbox reads:

viewport: {
  width: window.innerWidth,
  height: window.innerHeight,
  bottomInset: getMobileBottomNavInsetPx(),
}

Listeners: window scroll (capture) + resize only. No visualViewport resize / scroll.

computePortalListboxStyle subtracts bottomInset from space-below, then prefers opening below the anchor when that space is ≥ 120px. Preferred max height for token search is 240px. Flip-above exists but is not used when innerHeight still looks roomy under the IME.

getMobileBottomNavInsetPx measures only .app-mobile-nav-shell (DEX tab bar, #347). It does not include:

  • Keplr / Station / Cosmostation in-app URL + tab chrome (outside the page)
  • Android IME
  • visualViewport.offsetTop / height delta vs innerHeight

.token-select-dropdown is position: fixed; z-index: 200 (index.css). The mobile tab bar is z-index: 50. Keplr’s chrome is above the WebView and cannot be covered by any page z-index. Last options sit flush on that chrome.

Why a fast tap opens the URL bar

  1. Focus combobox → IME animates in → visual viewport collapses; layout viewport does not.
  2. Menu is sized against innerHeight → list extends into the occluded band.
  3. Option rows are py-2.5 (~40px), under the 44px coarse-pointer floor.
  4. onMouseDown on options calls preventDefault to keep focus; combined with onBlur close (150ms) and a moving IME, the first tap can miss the option and hit WebView chrome.
  5. Worse on short viewports (You Receive trigger is already low; receive menu has even less safe space).

detectWalletInAppBrowser() (detectWalletInAppBrowser.ts) already knows Keplr / Lunc Dash / Galaxy Station UAs. It is unused for picker geometry.


Why this is needed

Keplr in-app is the path many Android users actually use (and the documented alternate when Chrome WalletConnect is awkward). If picking LUNC / UST1 / USTR opens the wallet URL bar, Swap is unusable on the devices that need it most.

This is a hit-target / occlusion bug, not “users should tap slower.” Compact phones (≈360×640-class) have almost no gap between the last option and Keplr chrome once the IME is up.

Desktop combobox UX (#481) and mobile CLS (#498) stay; this issue adds visual-viewport + in-app chrome clearance and browse-without-IME on coarse/narrow viewports.


Constraints / guardrails

  1. Do not regress #481. Swap stays a searchable token combobox (factory-gated tokens prop, client-side filter, no GET /api/v1/tokens?q=). Do not revert Swap to scroll-only TokenSelect.
  2. Mint stays a button listbox. Do not force Mint into a combobox.
  3. Factory gate. onChange only emits ids in the allowed set; honor excludeToken. No external token lists.
  4. #498 CLS. Keep leading logo + .token-select-trigger--with-leading-logo while open; queryDraft === null until edit; no empty-label flash. Opening/closing must not shove the pay amount field.
  5. #350 / typed Enter. Empty query may keep current token at index 0; a ready typed query must commit the first hit, not prepend the current token.
  6. #244 APG. Arrow / Home / End / Enter / Escape / Tab stay. Screen readers must still see combobox + listbox + aria-activedescendant.
  7. Portal stays position: fixed on document.body. Do not put the menu back in document flow (chart/ticket CLS, #181).
  8. Sync first-frame position. Do not add a second setState pass for initial coords (usePortalListbox render-time read).
  9. DEX tab bar still reserved. Keep getMobileBottomNavInsetPx(). In-app / IME insets are additive, not a replacement.
  10. Do not UA-sniff as the only fix. Extra inset when in-app is detected is OK; geometry must also work in Android Chrome with the IME up (same innerHeight vs visualViewport bug).
  11. Do not try to style or hide Keplr chrome. Cannot. Stay inside the visual viewport with a conservative gap.
  12. WC-M7. In-app browser remains a valid connect path. Do not document “use Chrome only” as the fix.
  13. Security / XSS. Symbols render as text; logos via resolveTrustedTokenLogoUrl; query cap TOKEN_SEARCH_MAX_QUERY_LENGTH (128). No dangerouslySetInnerHTML.
  14. #562 / #534. Production gem hide and economic-first rank unchanged.
  15. #489. No always-on essay on the Swap card about “tap carefully.”
  16. E2E helpers keep targeting getByRole('combobox', { name: 'Select token you pay|receive' }) unless the mobile browse control is intentionally a button that still exposes a combobox name. Update e2e/helpers/token-select.ts in the same MR if the trigger role changes.
  17. Playwright workers stay 5 for e2e-smoke; do not add an e2e-tx dependency for this UI fix.

Relevant files

Shared positioning (primary)

  • frontend-dapp/src/components/ui/PortalListbox.tsx — viewport source + listeners
  • frontend-dapp/src/components/ui/portalListboxPosition.ts — flip / clamp / bottomInset
  • frontend-dapp/src/components/ui/__tests__/portalListboxPosition.test.ts
  • frontend-dapp/src/lib/mobileBottomNav.ts
  • frontend-dapp/src/utils/detectWalletInAppBrowser.ts
  • frontend-dapp/index.html — viewport meta (optional interactive-widget; measure before changing)

Combobox / IME

  • frontend-dapp/src/components/trade/TokenSearchSelect.tsx
  • frontend-dapp/src/components/trade/PairSearchSelect.tsx
  • frontend-dapp/src/components/ui/TokenSelect.tsx (button trigger; positioning only)
  • frontend-dapp/src/components/ui/MenuSelect.tsx (Charts pair, etc.; positioning only)
  • frontend-dapp/src/index.css — .token-select-dropdown, .token-select-option, .token-select-trigger

Call sites (must keep working)

  • frontend-dapp/src/pages/SwapPage.tsx
  • frontend-dapp/src/components/create/CreatePairTokenField.tsx
  • frontend-dapp/src/components/payments/PayWithAnyToken.tsx
  • frontend-dapp/src/components/pool/OneSidedAddCard.tsx, OneSidedWithdrawCard.tsx

Docs / playbooks

  • docs/frontend.md § Portal listboxes, § Token search, § Pair search
  • skills/AGENTS_FRONTEND_PORTAL_LISTBOX_CLS.md
  • skills/AGENTS_FRONTEND_TOKEN_SEARCH.md
  • QA_TEMPLATE.md (add a Keplr in-app picker row next to 1.2.12)

Regression tests to extend

  • frontend-dapp/src/components/trade/__tests__/TokenSearchSelect.test.tsx
  • frontend-dapp/e2e/swap-token-select-cls.spec.ts (#498)
  • frontend-dapp/e2e/helpers/token-select.ts
  • frontend-dapp/e2e/trade-pair-select-cls.spec.ts

Ship both layers in one MR. Positioning alone still opens Gboard and compresses the list onto chrome. IME-off alone still leaves the last row on the URL bar on short screens.

A — Visual viewport + chrome inset (shared)

  1. When window.visualViewport exists, pass its width/height (and offset if using fixed against the layout viewport) into computePortalListboxStyle.
  2. bottomInset = DEX tab bar + IME/visual occluded band + optional in-app chrome reserve (≈48–64px when detectWalletInAppBrowser().isInAppBrowser and coarse/narrow). Fail closed: extra inset is safer than overlap.
  3. Listen to visualViewport resize and scroll in usePortalListbox (same reducer bump as window resize). Keep first-frame sync read.
  4. Guarantee a minimum gap (full 44px finger + pad) between the listbox bottom and the reserved band so the last option cannot sit on chrome.
  5. Add unit cases: short visual viewport + keyboard-sized inset; in-app extra inset; flip-above when space-below collapses.

B — Browse without IME on coarse / narrow viewports

Pick one (do not fork two mobile UIs):

Option When to choose
B1. readOnly / inputMode="none" until the user taps an explicit search field (or types on a hardware keyboard) Smallest change; keeps anchored dropdown
B2. Button-looking trigger that opens the list; search <input> lives inside the portaled menu Clear tap-to-pick vs tap-to-search
B3. Full-viewport / bottom-sheet picker on ≤767px (list + search + dismiss), desktop stays anchored combobox Best for compact + Keplr; more CSS/a11y work

Prefer B3 if A still leaves You Receive options on chrome on a 640px-tall WebView; otherwise B2. Do not use a native <select> (breaks layout / z-order — see MenuSelect comment).

On desktop (pointer: fine and width ≥768) keep today’s focus-opens-list + type-to-filter behavior.

C — Hit targets and pointer capture

  • Coarse pointer: option min-height ≥ 44px.
  • overscroll-behavior: contain on the listbox so scroll does not leak to the WebView.
  • Keep option preventDefault on mouse down only if it does not eat the first Android tap; verify with a real device.

Acceptance criteria

  • On a phone-width viewport with a simulated IME / reduced visualViewport, the open token listbox is fully inside the visual viewport and does not overlap the DEX tab bar.
  • Extra bottom reserve when in-app UA is detected: last option has ≥44px clear gap above the reserved band (unit-tested geometry, not UA-only CSS).
  • On coarse/narrow viewports, opening the token menu to browse does not focus a text field / does not require the IME. Search still works via an explicit search control (or sheet field).
  • Fast tap on the last visible option selects that token; it must not be a dead zone that only the WebView chrome can receive.
  • Swap pay and receive both pass. Same geometry for PairSearchSelect (Trade/Limits) and other TokenSearchSelect call sites.
  • Desktop combobox, #350 typed Enter, #244 keys, #481 factory gate, #498 CLS E2E, #562 gem hide unchanged.
  • Mint TokenSelect still a button listbox.
  • Docs/playbooks mention visualViewport + in-app inset; QA template has a Keplr in-app picker row.

Test plan (all paths)

Unit

  1. computePortalListboxStyle with height = visual viewport (e.g. 390×400) + bottomInset 56 (tab) + 56 (in-app): menu maxHeight / top stay above the reserved band; flip-above when space-below < 120px.
  2. Desktop 1440×900, bottomInset 0: existing “opens below” numbers unchanged.
  3. Horizontal clamp still applies on a narrow visual viewport.
  4. TokenSearchSelect: coarse/narrow browse-open does not leave the search input as the active text field (or readOnly until search intent). Desktop click still opens list + allows type-to-filter.
  5. Factory gate / excludeToken / XSS (text-only symbol, allowlisted logo) tests stay green.
  6. detectWalletInAppBrowser still: Keplr UA = in-app; Android Chrome ≠ in-app.

Vitest / existing suites (must stay green)

cd frontend-dapp && npm test -- \
  src/components/ui/__tests__/portalListboxPosition.test.ts \
  src/components/trade/__tests__/TokenSearchSelect.test.tsx \
  src/components/ui/__tests__/TokenSelect.keyboard.test.tsx \
  src/utils/__tests__/detectWalletInAppBrowser.test.ts

Playwright (e2e-smoke, 5 workers)

  1. Keep e2e/swap-token-select-cls.spec.ts (#498) — trigger/amount Y/X stable on 390×844.
  2. New phone-width spec (no LocalTerra txs): open pay listbox; assert listbox getBoundingClientRect().bottom is below the trigger and above window.innerHeight - getMobileBottomNavInsetPx() - simulatedInset (page.evaluate with mocked visualViewport if the harness can inject it; otherwise assert gap to viewport bottom ≥ tab bar + 44px).
  3. Receive combobox: same bottom-clearance assert (this is the tighter layout).
  4. Select last option via click; combobox value updates; list closes; no navigation away from /swap.
  5. Trade pair combobox: open/close CLS + list stays in viewport (trade-pair-select-cls.spec.ts extended or sibling).
  6. Keyboard path: Arrow/Enter/Escape still work on desktop project viewport.

Manual (required — Playwright cannot drive Keplr chrome)

  1. Keplr Android in-app, compact phone (≤5.8"): Swap → tap You Pay → immediately tap last visible token (LUNC / similar). Must select. Must not focus Keplr URL bar.
  2. Same device: You Receive picker, same fast-pick.
  3. Same device: open menu, then use search, pick a filtered row.
  4. Larger Android (≈6.6"+) Keplr in-app: same pay/receive fast-pick.
  5. Android Chrome (not in-app) + WalletConnect: IME up on amount field, then open token menu — list must not sit under Gboard.
  6. Desktop Chrome + Keplr extension: typeahead / click unchanged.
  7. iOS Keplr / Safari: no new occlusion; sheet/dropdown still dismissible.
  8. Station / Cosmostation in-app if available (same chrome class; WC-M7).
  9. Light + dark theme; /create, /pool one-sided, invoice pay token — menu clears chrome.

Test plan (attack, hack, and abuse)

Picker changes sit on the selection path (token id → quote/execute). Treat as a trust-boundary UI.

  1. Selection injection. Crafted option ids / DOM extra <li> must not call onChange unless the id is in the factory-gated tokens list. Keep the existing allowedSet guard.
  2. XSS via symbol/name. Malicious CW20 symbol / name (HTML, javascript:) still render as text. Logos still pass resolveTrustedTokenLogoUrl. No raw HTML in option rows or the sheet title.
  3. Query DoS. Search field keeps 128-char cap + debounce. A sheet must not remove TOKEN_SEARCH_MAX_QUERY_LENGTH.
  4. excludeToken bypass. Search tricks / paste / keyboard must not select the other Swap leg.
  5. Gem / retail hide (#562). Production browse/search must not re-list hidden gems because of a new mobile sheet data path. Sheet must use the same tokens / filterRetailDiscovery* set as today.
  6. Clickjacking / overlay. New sheet/backdrop must not sit under Connect / pairing (z-[9999] / z-[10001]) or Legal gate. Dismiss target must not be a transparent full-screen layer that intercepts Keplr chrome (we cannot cover chrome; do not try). Backdrop click-outside must not generate a phantom token onChange.
  7. Focus steal. Opening the menu must not window.open, change location, or focus an off-page control. Blur timers must not select a token on outside tap.
  8. UA spoof. Fake Keplr UA must only add inset (safer). It must not unlock hidden tokens, skip Legal, or change quotes.
  9. viewport / visualViewport spoof. Extreme visualViewport values (0, NaN, huge) must clamp to a usable menu, not throw or cover the full window including the tab bar.
  10. Touch-jack after IME. If IME closes mid-gesture, the next tap must not apply to a token that slid under the finger (ignore click if the option moved > threshold, or wait for a stable layout frame).

Verification criteria

Done when:

  1. Manual Keplr Android compact and large: 10 consecutive fast picks on pay and 10 on receive all select the intended token; 0 URL-bar activations.
  2. Unit + listed Vitest files green; #498 CLS E2E green; new clearance E2E green on e2e-smoke.
  3. Desktop type-to-search and #350 Enter-first-hit still pass existing TokenSearchSelect tests.
  4. Reviewer can see visualViewport (or equivalent) in usePortalListbox and a coarse/narrow browse path that does not open the IME by default.
  5. Playbook + docs/frontend.md + QA row updated so the next agent does not revert to innerHeight-only positioning.

Not done if the only change is “tap slower” copy, a Keplr-only CSS hack, or a desktop-only dropdown tweak.

## Summary On **Android Keplr in-app browser**, tapping a token in the Swap **You Pay / You Receive** menu often activates the **Keplr URL / navigation chrome** instead of selecting the coin. Compact phones make this nearly 100% of “fast pick” attempts. Desktop and wide viewports are fine. Community reports (2026-08, anonymized): multiple Android devices (compact and large) in Keplr’s in-app browser on `https://dex.cl8y.com/`. Opening the token menu with the IME up places the last list rows a few pixels above Keplr’s bottom URL bar; a quick tap lands on the bar (keyboard / tab switcher / overflow) rather than the option. This is **not** a wallet-connect bug. Keplr in-app remains a supported path (**WC-M7** / [#554](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/554)). The picker UI does not reserve space for in-app chrome or the visual viewport. Related (do not regress): [#481](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/481) (Swap combobox), [#498](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/498) (mobile CLS), [#181](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/181) (portal layout), [#244](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/244) (listbox keyboard), [#347](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/347) (mobile tab bar), [#350](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/350) (typed Enter = first hit). Label leak for `uluna`/`uusd` is a **separate** picker issue ([#630](https://gitlab.com/PlasticDigits/cl8y-dex-terraclassic/-/issues/630)). --- ## Current codebase ### What users tap Swap pay/receive use [`TokenSearchSelect`](frontend-dapp/src/components/trade/TokenSearchSelect.tsx) — an `<input type="text" role="combobox">`. `onFocus` / `onClick` open a **portaled** `<ul role="listbox">` on `document.body`. Same control is reused on Create Pair, pay-with-any-token, and Pool one-sided add/withdraw. Trade / Limits use the twin [`PairSearchSelect`](frontend-dapp/src/components/trade/PairSearchSelect.tsx) (also an input combobox). Mint keeps button-trigger [`TokenSelect`](frontend-dapp/src/components/ui/TokenSelect.tsx). Tapping the trigger **focuses a text field**, so Android Gboard opens even when the user only wants to pick a visible row. Viewport meta in [`frontend-dapp/index.html`](frontend-dapp/index.html) is `width=device-width, initial-scale=1.0` with **no** `interactive-widget`. Chromium’s default is `resizes-visual`: `window.innerHeight` stays large; only `visualViewport` shrinks. ### How the menu is placed [`usePortalListbox`](frontend-dapp/src/components/ui/PortalListbox.tsx) reads: ```ts viewport: { width: window.innerWidth, height: window.innerHeight, bottomInset: getMobileBottomNavInsetPx(), } ``` Listeners: `window` `scroll` (capture) + `resize` only. **No** `visualViewport` `resize` / `scroll`. [`computePortalListboxStyle`](frontend-dapp/src/components/ui/portalListboxPosition.ts) subtracts `bottomInset` from space-below, then prefers opening **below** the anchor when that space is ≥ 120px. Preferred max height for token search is **240px**. Flip-above exists but is not used when `innerHeight` still looks roomy under the IME. [`getMobileBottomNavInsetPx`](frontend-dapp/src/lib/mobileBottomNav.ts) measures **only** `.app-mobile-nav-shell` (DEX tab bar, `#347`). It does **not** include: - Keplr / Station / Cosmostation in-app URL + tab chrome (outside the page) - Android IME - `visualViewport.offsetTop` / height delta vs `innerHeight` `.token-select-dropdown` is `position: fixed; z-index: 200` ([`index.css`](frontend-dapp/src/index.css)). The mobile tab bar is `z-index: 50`. Keplr’s chrome is **above** the WebView and cannot be covered by any page `z-index`. Last options sit flush on that chrome. ### Why a fast tap opens the URL bar 1. Focus combobox → IME animates in → visual viewport collapses; layout viewport does not. 2. Menu is sized against `innerHeight` → list extends into the occluded band. 3. Option rows are `py-2.5` (~40px), under the 44px coarse-pointer floor. 4. `onMouseDown` on options calls `preventDefault` to keep focus; combined with `onBlur` close (150ms) and a moving IME, the first tap can miss the option and hit WebView chrome. 5. Worse on short viewports (You Receive trigger is already low; receive menu has even less safe space). `detectWalletInAppBrowser()` ([`detectWalletInAppBrowser.ts`](frontend-dapp/src/utils/detectWalletInAppBrowser.ts)) already knows Keplr / Lunc Dash / Galaxy Station UAs. It is unused for picker geometry. --- ## Why this is needed Keplr in-app is the path many Android users actually use (and the documented alternate when Chrome WalletConnect is awkward). If picking LUNC / UST1 / USTR opens the wallet URL bar, Swap is unusable on the devices that need it most. This is a **hit-target / occlusion** bug, not “users should tap slower.” Compact phones (≈360×640-class) have almost no gap between the last option and Keplr chrome once the IME is up. Desktop combobox UX (#481) and mobile CLS (#498) stay; this issue adds **visual-viewport + in-app chrome** clearance and **browse-without-IME** on coarse/narrow viewports. --- ## Constraints / guardrails 1. **Do not regress #481.** Swap stays a searchable token combobox (factory-gated `tokens` prop, client-side filter, no `GET /api/v1/tokens?q=`). Do not revert Swap to scroll-only `TokenSelect`. 2. **Mint stays a button listbox.** Do not force Mint into a combobox. 3. **Factory gate.** `onChange` only emits ids in the allowed set; honor `excludeToken`. No external token lists. 4. **#498 CLS.** Keep leading logo + `.token-select-trigger--with-leading-logo` while open; `queryDraft === null` until edit; no empty-label flash. Opening/closing must not shove the pay amount field. 5. **#350 / typed Enter.** Empty query may keep current token at index 0; a **ready typed** query must commit the first hit, not prepend the current token. 6. **#244 APG.** Arrow / Home / End / Enter / Escape / Tab stay. Screen readers must still see `combobox` + `listbox` + `aria-activedescendant`. 7. **Portal stays `position: fixed` on `document.body`.** Do not put the menu back in document flow (chart/ticket CLS, #181). 8. **Sync first-frame position.** Do not add a second `setState` pass for initial coords (`usePortalListbox` render-time read). 9. **DEX tab bar still reserved.** Keep `getMobileBottomNavInsetPx()`. In-app / IME insets are **additive**, not a replacement. 10. **Do not UA-sniff as the only fix.** Extra inset when in-app is detected is OK; geometry must also work in Android Chrome with the IME up (same `innerHeight` vs `visualViewport` bug). 11. **Do not try to style or hide Keplr chrome.** Cannot. Stay inside the visual viewport with a conservative gap. 12. **WC-M7.** In-app browser remains a valid connect path. Do not document “use Chrome only” as the fix. 13. **Security / XSS.** Symbols render as text; logos via `resolveTrustedTokenLogoUrl`; query cap `TOKEN_SEARCH_MAX_QUERY_LENGTH` (128). No `dangerouslySetInnerHTML`. 14. **#562 / #534.** Production gem hide and economic-first rank unchanged. 15. **#489.** No always-on essay on the Swap card about “tap carefully.” 16. **E2E helpers** keep targeting `getByRole('combobox', { name: 'Select token you pay|receive' })` unless the mobile browse control is intentionally a button that still exposes a combobox name. Update [`e2e/helpers/token-select.ts`](frontend-dapp/e2e/helpers/token-select.ts) in the same MR if the trigger role changes. 17. **Playwright workers stay 5** for `e2e-smoke`; do not add an `e2e-tx` dependency for this UI fix. --- ## Relevant files **Shared positioning (primary)** - `frontend-dapp/src/components/ui/PortalListbox.tsx` — viewport source + listeners - `frontend-dapp/src/components/ui/portalListboxPosition.ts` — flip / clamp / `bottomInset` - `frontend-dapp/src/components/ui/__tests__/portalListboxPosition.test.ts` - `frontend-dapp/src/lib/mobileBottomNav.ts` - `frontend-dapp/src/utils/detectWalletInAppBrowser.ts` - `frontend-dapp/index.html` — viewport meta (optional `interactive-widget`; measure before changing) **Combobox / IME** - `frontend-dapp/src/components/trade/TokenSearchSelect.tsx` - `frontend-dapp/src/components/trade/PairSearchSelect.tsx` - `frontend-dapp/src/components/ui/TokenSelect.tsx` (button trigger; positioning only) - `frontend-dapp/src/components/ui/MenuSelect.tsx` (Charts pair, etc.; positioning only) - `frontend-dapp/src/index.css` — `.token-select-dropdown`, `.token-select-option`, `.token-select-trigger` **Call sites (must keep working)** - `frontend-dapp/src/pages/SwapPage.tsx` - `frontend-dapp/src/components/create/CreatePairTokenField.tsx` - `frontend-dapp/src/components/payments/PayWithAnyToken.tsx` - `frontend-dapp/src/components/pool/OneSidedAddCard.tsx`, `OneSidedWithdrawCard.tsx` **Docs / playbooks** - `docs/frontend.md` § Portal listboxes, § Token search, § Pair search - `skills/AGENTS_FRONTEND_PORTAL_LISTBOX_CLS.md` - `skills/AGENTS_FRONTEND_TOKEN_SEARCH.md` - `QA_TEMPLATE.md` (add a Keplr in-app picker row next to 1.2.12) **Regression tests to extend** - `frontend-dapp/src/components/trade/__tests__/TokenSearchSelect.test.tsx` - `frontend-dapp/e2e/swap-token-select-cls.spec.ts` (#498) - `frontend-dapp/e2e/helpers/token-select.ts` - `frontend-dapp/e2e/trade-pair-select-cls.spec.ts` --- ## Recommended direction Ship **both** layers in one MR. Positioning alone still opens Gboard and compresses the list onto chrome. IME-off alone still leaves the last row on the URL bar on short screens. ### A — Visual viewport + chrome inset (shared) 1. When `window.visualViewport` exists, pass **its** width/height (and offset if using `fixed` against the layout viewport) into `computePortalListboxStyle`. 2. `bottomInset` = DEX tab bar **+** IME/visual occluded band **+** optional in-app chrome reserve (≈48–64px when `detectWalletInAppBrowser().isInAppBrowser` **and** coarse/narrow). Fail closed: extra inset is safer than overlap. 3. Listen to `visualViewport` `resize` and `scroll` in `usePortalListbox` (same reducer bump as window resize). Keep first-frame sync read. 4. Guarantee a **minimum gap** (full 44px finger + pad) between the listbox bottom and the reserved band so the last option cannot sit on chrome. 5. Add unit cases: short visual viewport + keyboard-sized inset; in-app extra inset; flip-above when space-below collapses. ### B — Browse without IME on coarse / narrow viewports Pick **one** (do not fork two mobile UIs): | Option | When to choose | |--------|----------------| | **B1.** `readOnly` / `inputMode="none"` until the user taps an explicit search field (or types on a hardware keyboard) | Smallest change; keeps anchored dropdown | | **B2.** Button-looking trigger that opens the list; search `<input>` lives **inside** the portaled menu | Clear tap-to-pick vs tap-to-search | | **B3.** Full-viewport / bottom-sheet picker on ≤767px (list + search + dismiss), desktop stays anchored combobox | Best for compact + Keplr; more CSS/a11y work | Prefer **B3** if A still leaves You Receive options on chrome on a 640px-tall WebView; otherwise **B2**. Do not use a native `<select>` (breaks layout / z-order — see `MenuSelect` comment). On desktop (`pointer: fine` and width ≥768) keep today’s focus-opens-list + type-to-filter behavior. ### C — Hit targets and pointer capture - Coarse pointer: option min-height ≥ 44px. - `overscroll-behavior: contain` on the listbox so scroll does not leak to the WebView. - Keep option `preventDefault` on mouse down only if it does not eat the first Android tap; verify with a real device. --- ## Acceptance criteria - [ ] On a phone-width viewport with a simulated IME / reduced `visualViewport`, the open token listbox is fully inside the visual viewport and **does not overlap** the DEX tab bar. - [ ] Extra bottom reserve when in-app UA is detected: last option has ≥44px clear gap above the reserved band (unit-tested geometry, not UA-only CSS). - [ ] On coarse/narrow viewports, opening the token menu to **browse** does **not** focus a text field / does not require the IME. Search still works via an explicit search control (or sheet field). - [ ] Fast tap on the last visible option selects that token; it must not be a dead zone that only the WebView chrome can receive. - [ ] Swap pay **and** receive both pass. Same geometry for `PairSearchSelect` (Trade/Limits) and other `TokenSearchSelect` call sites. - [ ] Desktop combobox, #350 typed Enter, #244 keys, #481 factory gate, #498 CLS E2E, #562 gem hide unchanged. - [ ] Mint `TokenSelect` still a button listbox. - [ ] Docs/playbooks mention visualViewport + in-app inset; QA template has a Keplr in-app picker row. --- ## Test plan (all paths) ### Unit 1. `computePortalListboxStyle` with `height` = visual viewport (e.g. 390×400) + `bottomInset` 56 (tab) + 56 (in-app): menu `maxHeight` / `top` stay above the reserved band; flip-above when space-below &lt; 120px. 2. Desktop 1440×900, `bottomInset` 0: existing “opens below” numbers unchanged. 3. Horizontal clamp still applies on a narrow visual viewport. 4. `TokenSearchSelect`: coarse/narrow browse-open does not leave the search input as the active text field (or `readOnly` until search intent). Desktop click still opens list + allows type-to-filter. 5. Factory gate / `excludeToken` / XSS (text-only symbol, allowlisted logo) tests stay green. 6. `detectWalletInAppBrowser` still: Keplr UA = in-app; Android Chrome ≠ in-app. ### Vitest / existing suites (must stay green) ```text cd frontend-dapp && npm test -- \ src/components/ui/__tests__/portalListboxPosition.test.ts \ src/components/trade/__tests__/TokenSearchSelect.test.tsx \ src/components/ui/__tests__/TokenSelect.keyboard.test.tsx \ src/utils/__tests__/detectWalletInAppBrowser.test.ts ``` ### Playwright (`e2e-smoke`, 5 workers) 1. Keep `e2e/swap-token-select-cls.spec.ts` (#498) — trigger/amount Y/X stable on 390×844. 2. New phone-width spec (no LocalTerra txs): open pay listbox; assert listbox `getBoundingClientRect().bottom` is below the trigger and **above** `window.innerHeight - getMobileBottomNavInsetPx() - simulatedInset` (page.evaluate with mocked `visualViewport` if the harness can inject it; otherwise assert gap to viewport bottom ≥ tab bar + 44px). 3. Receive combobox: same bottom-clearance assert (this is the tighter layout). 4. Select last option via click; combobox value updates; list closes; no navigation away from `/swap`. 5. Trade pair combobox: open/close CLS + list stays in viewport (`trade-pair-select-cls.spec.ts` extended or sibling). 6. Keyboard path: Arrow/Enter/Escape still work on desktop project viewport. ### Manual (required — Playwright cannot drive Keplr chrome) 1. **Keplr Android in-app**, compact phone (≤5.8"): Swap → tap You Pay → immediately tap last visible token (LUNC / similar). Must select. Must **not** focus Keplr URL bar. 2. Same device: You Receive picker, same fast-pick. 3. Same device: open menu, **then** use search, pick a filtered row. 4. Larger Android (≈6.6"+) Keplr in-app: same pay/receive fast-pick. 5. Android **Chrome** (not in-app) + WalletConnect: IME up on amount field, then open token menu — list must not sit under Gboard. 6. Desktop Chrome + Keplr extension: typeahead / click unchanged. 7. iOS Keplr / Safari: no new occlusion; sheet/dropdown still dismissible. 8. Station / Cosmostation in-app if available (same chrome class; WC-M7). 9. Light + dark theme; `/create`, `/pool` one-sided, invoice pay token — menu clears chrome. --- ## Test plan (attack, hack, and abuse) Picker changes sit on the **selection** path (token id → quote/execute). Treat as a trust-boundary UI. 1. **Selection injection.** Crafted option ids / DOM extra `<li>` must not call `onChange` unless the id is in the factory-gated `tokens` list. Keep the existing `allowedSet` guard. 2. **XSS via symbol/name.** Malicious CW20 `symbol` / `name` (HTML, `javascript:`) still render as **text**. Logos still pass `resolveTrustedTokenLogoUrl`. No raw HTML in option rows or the sheet title. 3. **Query DoS.** Search field keeps 128-char cap + debounce. A sheet must not remove `TOKEN_SEARCH_MAX_QUERY_LENGTH`. 4. **excludeToken bypass.** Search tricks / paste / keyboard must not select the other Swap leg. 5. **Gem / retail hide (#562).** Production browse/search must not re-list hidden gems because of a new mobile sheet data path. Sheet must use the same `tokens` / `filterRetailDiscovery*` set as today. 6. **Clickjacking / overlay.** New sheet/backdrop must not sit under Connect / pairing (`z-[9999]` / `z-[10001]`) or Legal gate. Dismiss target must not be a transparent full-screen layer that intercepts Keplr chrome (we cannot cover chrome; do not try). Backdrop click-outside must not generate a phantom token `onChange`. 7. **Focus steal.** Opening the menu must not `window.open`, change `location`, or focus an off-page control. Blur timers must not select a token on outside tap. 8. **UA spoof.** Fake `Keplr` UA must only add inset (safer). It must not unlock hidden tokens, skip Legal, or change quotes. 9. **viewport / visualViewport spoof.** Extreme `visualViewport` values (0, NaN, huge) must clamp to a usable menu, not throw or cover the full window including the tab bar. 10. **Touch-jack after IME.** If IME closes mid-gesture, the next tap must not apply to a token that slid under the finger (ignore click if the option moved &gt; threshold, or wait for a stable layout frame). --- ## Verification criteria Done when: 1. Manual Keplr Android compact **and** large: 10 consecutive fast picks on pay and 10 on receive all select the intended token; 0 URL-bar activations. 2. Unit + listed Vitest files green; #498 CLS E2E green; new clearance E2E green on `e2e-smoke`. 3. Desktop type-to-search and #350 Enter-first-hit still pass existing `TokenSearchSelect` tests. 4. Reviewer can see `visualViewport` (or equivalent) in `usePortalListbox` and a coarse/narrow browse path that does not open the IME by default. 5. Playbook + `docs/frontend.md` + QA row updated so the next agent does not revert to `innerHeight`-only positioning. Not done if the only change is “tap slower” copy, a Keplr-only CSS hack, or a desktop-only dropdown tweak.
PlasticDigits commented 2026-08-25 01:57:09 +00:00 (Migrated from gitlab.com)

marked as related to #481

marked as related to #481
PlasticDigits commented 2026-08-25 01:57:10 +00:00 (Migrated from gitlab.com)

marked as related to #498

marked as related to #498
PlasticDigits commented 2026-08-25 01:57:10 +00:00 (Migrated from gitlab.com)

marked as related to #554

marked as related to #554
PlasticDigits commented 2026-08-25 06:07:52 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1144

mentioned in merge request !1144
PlasticDigits commented 2026-08-25 06:26:41 +00:00 (Migrated from gitlab.com)

mentioned in commit 6af8261cfd

mentioned in commit 6af8261cfdbd8417bdac1f1a6afdee298538e672
PlasticDigits (Migrated from gitlab.com) closed this issue 2026-08-25 06:26:41 +00:00
PlasticDigits commented 2026-08-25 06:29:22 +00:00 (Migrated from gitlab.com)

!1144 merged to main (6af8261c) without waiting for CI. Conflicts with !1142 were docs/Makefile/AGENTS only; TokenSearchSelect auto-merged. make verify-issue-632 10/10 (V632-1–V632-8 unit + docs). Playwright chain specs were skipped (VERIFY_ISSUE_632_CHAIN unset).

Leftover (Playwright cannot drive wallet chrome):

  • Manual Android Keplr in-app: 10 fast picks on You Pay / You Receive (compact + large).
  • Manual Android Chrome + IME on amount, then open token menu — list not under Gboard.
  • Desktop Chrome + Keplr extension: typeahead / #350 Enter unchanged.

Do not reopen unless a merged invariant is wrong. Manual leftovers go on the post-merge leftover issue.

!1144 merged to `main` (`6af8261c`) without waiting for CI. Conflicts with !1142 were docs/Makefile/AGENTS only; TokenSearchSelect auto-merged. `make verify-issue-632` **10/10** (V632-1–V632-8 unit + docs). Playwright chain specs were **skipped** (`VERIFY_ISSUE_632_CHAIN` unset). **Leftover (Playwright cannot drive wallet chrome):** - Manual Android Keplr in-app: 10 fast picks on You Pay / You Receive (compact + large). - Manual Android Chrome + IME on amount, then open token menu — list not under Gboard. - Desktop Chrome + Keplr extension: typeahead / #350 Enter unchanged. Do **not** reopen unless a merged invariant is wrong. Manual leftovers go on the post-merge leftover issue.
PlasticDigits commented 2026-08-25 06:29:43 +00:00 (Migrated from gitlab.com)

mentioned in issue #638

mentioned in issue #638
PlasticDigits commented 2026-08-25 06:29:46 +00:00 (Migrated from gitlab.com)

marked as related to #638

marked as related to #638
PlasticDigits commented 2026-08-25 06:29:59 +00:00 (Migrated from gitlab.com)

Post-merge leftovers for the !1140/!1142/!1143/!1144/!1145 pass: #638.

Post-merge leftovers for the !1140/!1142/!1143/!1144/!1145 pass: #638.
PlasticDigits commented 2026-08-26 01:06:41 +00:00 (Migrated from gitlab.com)

mentioned in issue #651

mentioned in issue #651
PlasticDigits commented 2026-08-26 01:49:29 +00:00 (Migrated from gitlab.com)

mentioned in merge request !1153

mentioned in merge request !1153
PlasticDigits commented 2026-08-26 04:08:09 +00:00 (Migrated from gitlab.com)

mentioned in issue #659

mentioned in issue #659
PlasticDigits commented 2026-08-26 04:16:12 +00:00 (Migrated from gitlab.com)

mentioned in issue #665

mentioned in issue #665
PlasticDigits commented 2026-08-31 04:56:53 +00:00 (Migrated from gitlab.com)

mentioned in issue #711

mentioned in issue #711
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
code/cl8y-dex-terraclassic#632
No description provided.