hermes(webui): fix mobile composer hidden behind the gesture bar

On an Android standalone PWA the composer's bottom control row was rendering
about one line below the visible viewport, behind the system gesture bar, so
those controls were unreachable. The theme already pads the titlebar with
env(safe-area-inset-top/left/right), but the viewport meta never opted into
viewport-fit=cover, so every safe-area inset collapsed to 0 and the bottom edge
had no reservation.

Add viewport-fit=cover to the viewport meta (base patch) so the insets carry
real values, and reserve env(safe-area-inset-bottom) at the bottom of the
composer (brand.css), additive with the app's existing --keyboard-bottom-inset
and absorbed by the flex-1 message scroller so total height stays within the
viewport. Inert on desktop (env() resolves to 0). Dockerfile verifies the meta
patch landed; brand/dockerfile contracts covered by tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
jenkins 2026-08-24 23:32:26 -03:00
parent b59f754f5a
commit ffa477585d
4 changed files with 49 additions and 0 deletions

View File

@ -84,6 +84,7 @@ RUN /opt/hermes/.venv/bin/python -c 'import cryptography, yaml' \
&& grep -Fq "TELEGRAM_PROJECT_NAME = 'Telegram'" /opt/hermes-webui/api/models.py \
&& grep -Fq "'atlas/auto/maximum': 'Automatic · Maximum'" /opt/hermes-webui/static/panels.js \
&& grep -Fq 'Atlas Jetson (private)' /opt/hermes-webui/static/index.html \
&& grep -Fq 'user-scalable=no, viewport-fit=cover' /opt/hermes-webui/static/index.html \
&& grep -Fq 'HERMES_WEBUI_ATLAS_TTS_URL' /opt/hermes-webui/api/routes.py \
&& grep -Fq 'settings["webui_bundle_version"]' /opt/hermes-webui/api/routes.py \
&& grep -Fq 'settings.webui_bundle_version||settings.webui_version' /opt/hermes-webui/static/panels.js \

View File

@ -21,6 +21,26 @@ if source.count(before) != 1:
raise SystemExit("Hermes WebUI xhigh UI patch context changed")
index.write_text(source.replace(before, "", 1), encoding="utf-8")
# Enable edge-to-edge safe-area insets on mobile / standalone PWAs. The theme
# already pads the titlebar with env(safe-area-inset-top/left/right) and the
# composer reserves env(safe-area-inset-bottom) (hermes-brand.css), but env()
# resolves to 0 unless the viewport opts into viewport-fit=cover. Android
# standalone PWAs render the webview edge-to-edge regardless, so without cover
# there is no inset to pad with and the composer's control row hides behind the
# gesture bar. Opt in so those insets carry real values.
source = index.read_text(encoding="utf-8")
before = (
'<meta name="viewport" content="width=device-width, initial-scale=1, '
'maximum-scale=1, user-scalable=no">'
)
after = (
'<meta name="viewport" content="width=device-width, initial-scale=1, '
'maximum-scale=1, user-scalable=no, viewport-fit=cover">'
)
if source.count(before) != 1:
raise SystemExit("Hermes WebUI viewport-fit patch context changed")
index.write_text(source.replace(before, after, 1), encoding="utf-8")
# oauth2-proxy returns 401 for browser API and health probes when the secure
# session expires. Re-enter OIDC with the complete return path.
ui = Path("/opt/hermes-webui/static/ui.js")

View File

@ -250,3 +250,14 @@
border-radius: 19px;
}
}
/* Reserve the device safe-area (home indicator / gesture bar) below the
composer so its control row is never hidden behind it. Requires
viewport-fit=cover on the viewport meta (set in hermes-webui-base-patch.py);
env() resolves to 0 on devices without an inset, so this is inert on desktop.
Additive with the keyboard inset the app already manages via
--keyboard-bottom-inset, and absorbed by the flex-1 message scroller so the
total column height stays within the viewport (body is height:100dvh). */
.composer-wrap {
padding-bottom: calc(16px + var(--keyboard-bottom-inset, 0px) + env(safe-area-inset-bottom, 0px)) !important;
}

View File

@ -379,6 +379,23 @@ def test_brand_css_is_accessible_dark_and_reduced_motion_aware() -> None:
assert "transition: none !important" in reduced
assert "transform: none !important" in reduced
assert "animation:" not in css
# The composer reserves the device bottom safe-area (home indicator / gesture
# bar) so its control row is not hidden behind it, additive with the app's
# keyboard inset. env() is 0 on inset-less devices, so this is desktop-inert.
assert ".composer-wrap" in css
composer = css.rsplit(".composer-wrap {", 1)[1].split("}", 1)[0]
assert "env(safe-area-inset-bottom, 0px)" in composer
assert "var(--keyboard-bottom-inset, 0px)" in composer
def test_base_patch_and_dockerfile_opt_into_edge_to_edge_safe_area() -> None:
"""viewport-fit=cover is required for env(safe-area-inset-*) to carry real
values; the base patch adds it and the Dockerfile verifies it landed."""
base_patch = (DOCKERFILES / "hermes-webui-base-patch.py").read_text(encoding="utf-8")
assert "viewport-fit=cover" in base_patch
assert "user-scalable=no, viewport-fit=cover" in base_patch
dockerfile = (DOCKERFILES / "Dockerfile.hermes-webui").read_text(encoding="utf-8")
assert "grep -Fq 'user-scalable=no, viewport-fit=cover'" in dockerfile
def test_empty_state_uses_canonical_character_without_inline_staff(