From 793f9c8cad672105608756109db9eb0bf36fb01c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Sun, 27 Sep 2026 20:36:45 +0000 Subject: [PATCH] A model menu that stays on the phone Inside a chat the picker sits mid-bar with the panel buttons to its right, and its menu opened from the picker's right edge -- at 390px it spanned x = -132..226, cutting every model's name off. Below 48rem the bar is now the containing block and the menu is pinned between its edges (capped at 24rem). Opening it on a touchscreen no longer focuses the filter, which raised the keyboard over half the list. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 17 +++++++++++++++++ src/lembas/__init__.py | 2 +- src/lembas/web/static/css/app.css | 17 +++++++++++++++++ src/lembas/web/static/js/ui.js | 5 +++++ tests/test_layout_bounds.py | 16 ++++++++++++++++ tests/test_model_lists.py | 19 +++++++++++++++++++ 6 files changed, 75 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a0024..243f258 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,23 @@ for 1.0.0 have something to be assembled from. ## Unreleased +## 1.8.3 + +The model picker on a phone, which could not be read once a chat was open. + +- **The model menu no longer runs off the left of the screen.** Inside a chat + the picker sits in the middle of the top bar, with the panel buttons to its + right, and its menu opened from the picker's right edge — so on a phone most + of it was off the screen and every model's name was cut off. On a narrow + screen the menu now hangs from the bar itself, edge to edge, and every name is + whole. Wider screens are unchanged. + +- **Opening it on a touchscreen no longer raises the keyboard.** With more than + eight models the menu has a filter box, and it took the focus on opening — so + the keyboard came up and covered half the list you had opened it to choose + from. On a touchscreen the chosen model takes the focus instead; the filter is + one tap away. With a mouse, typing straight into the filter works as before. + ## 1.8.2 The model lists, made readable. Both printed every capability switch as a tag — diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index 56e1ade..58376ab 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "1.8.2" +__version__ = "1.8.3" diff --git a/src/lembas/web/static/css/app.css b/src/lembas/web/static/css/app.css index 5bc32ca..4b1b4e4 100644 --- a/src/lembas/web/static/css/app.css +++ b/src/lembas/web/static/css/app.css @@ -1377,6 +1377,23 @@ body.is-resizing .canvas__body { pointer-events: none; } which nothing else on the screen tells you -- gets the room back. */ .topbar__actions .picker__label { display: none; } + /* And the menu is the bar's, not the picker's. Anchored to the picker it + opens `right: 0` of a button that sits mid-bar with the panel buttons to + its right, so a 24rem menu ran off the left edge of a 390px phone and cut + every name in half. Taking `position` off the picker makes the bar the + containing block: the menu spans the bar under it, whatever sits where -- + up to its usual 24rem, held at the bar's right edge by the auto margin. */ + .topbar { position: relative; } + .topbar__actions .picker { position: static; } + .topbar__actions .picker__menu { + left: max(var(--sp-2), var(--safe-left)); + right: max(var(--sp-2), var(--safe-right)); + width: auto; + max-width: 24rem; + margin-left: auto; + } + .topbar__actions .picker__list { max-height: min(22rem, 60dvh); } + .sidebar { position: fixed; inset: 0 auto 0 0; diff --git a/src/lembas/web/static/js/ui.js b/src/lembas/web/static/js/ui.js index dec9a35..2b9b78f 100644 --- a/src/lembas/web/static/js/ui.js +++ b/src/lembas/web/static/js/ui.js @@ -320,6 +320,11 @@ if (filter) { filter.value = ""; applyFilter(menu, ""); + } + // Not on a touchscreen: focusing a text field there raises the keyboard, + // which covers half the list the finger came to choose from. The filter + // is one tap away for whoever wants it. + if (filter && !window.matchMedia("(hover: none)").matches) { filter.focus(); } else { var selected = menu.querySelector(".picker__option.is-selected") || diff --git a/tests/test_layout_bounds.py b/tests/test_layout_bounds.py index 1825437..8ccc2d6 100644 --- a/tests/test_layout_bounds.py +++ b/tests/test_layout_bounds.py @@ -208,3 +208,19 @@ def test_the_desktop_minimum_is_still_declared(): for token in ("--terminal-width-min", "--canvas-width-min"): assert f"{token}:" in TOKENS assert f"var({token})" in APP_CSS + + +def test_the_topbar_model_menu_belongs_to_the_bar_on_a_phone(): + """1.8.3. Anchored to the picker, the menu opened `right: 0` of a button that + sits mid-bar with the panel buttons to its right, so on a 390px phone a 24rem + menu started 132px left of the screen and every model's name was cut off. + + Below the phone breakpoint the picker gives up `position`, which makes the bar + the containing block, and the menu is pinned between the bar's two edges.""" + body = _media_body(APP_CSS, "48rem") + assert re.search(r"\.topbar\s*\{\s*position:\s*relative", body) + assert re.search(r"\.topbar__actions \.picker\s*\{\s*position:\s*static", body) + menu = re.search(r"\.topbar__actions \.picker__menu\s*\{([^}]*)\}", body) + assert menu, "the topbar's menu is not placed on a phone" + for declaration in ("left:", "right:", "width: auto"): + assert declaration in menu.group(1), f"{declaration} missing from the phone menu" diff --git a/tests/test_model_lists.py b/tests/test_model_lists.py index c465836..1257094 100644 --- a/tests/test_model_lists.py +++ b/tests/test_model_lists.py @@ -111,3 +111,22 @@ def test_settings_lists_the_models_with_their_tags_below_the_name(client, models assert listing.count("#i-eye") == 1 assert listing.count('class="model-list__more model-list__tags"') == 2 assert "tool_fetch" in listing + + +def test_opening_the_picker_on_a_touchscreen_does_not_raise_the_keyboard(): + """1.8.3. With more than eight models the menu has a filter, and `open()` + focused it -- which on a phone raises the keyboard over half the list the + finger came to choose from. The focus is gated on `(hover: none)`, the same + query the stylesheet uses for touch.""" + from pathlib import Path + + import lembas + + js = (Path(lembas.__file__).parent / "web/static/js/ui.js").read_text(encoding="utf-8") + start = js.index("function open(picker)") + body = js[start : js.index("function applyFilter", start)] + assert "filter.focus()" in body, "the filter is no longer focused anywhere -- test is blind" + gated = r'if \(filter && !window\.matchMedia\("\(hover: none\)"\)\.matches\)\s*\{' + assert re.search(gated + r"\s*filter\.focus\(\)", body), ( + "the filter is focused on open without asking whether this is a touchscreen" + )