diff --git a/dockerfiles/Dockerfile.hermes-webui b/dockerfiles/Dockerfile.hermes-webui
index f226ad41..5ebf296b 100644
--- a/dockerfiles/Dockerfile.hermes-webui
+++ b/dockerfiles/Dockerfile.hermes-webui
@@ -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 \
diff --git a/dockerfiles/hermes-webui-base-patch.py b/dockerfiles/hermes-webui-base-patch.py
index 1629d25a..2275cfc1 100644
--- a/dockerfiles/hermes-webui-base-patch.py
+++ b/dockerfiles/hermes-webui-base-patch.py
@@ -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 = (
+ ''
+)
+after = (
+ ''
+)
+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")
diff --git a/dockerfiles/hermes-webui-brand.css b/dockerfiles/hermes-webui-brand.css
index 16ae423f..fa9302eb 100644
--- a/dockerfiles/hermes-webui-brand.css
+++ b/dockerfiles/hermes-webui-brand.css
@@ -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;
+}
diff --git a/testing/tests/test_hermes_webui_brand.py b/testing/tests/test_hermes_webui_brand.py
index 7f420600..dde62964 100644
--- a/testing/tests/test_hermes_webui_brand.py
+++ b/testing/tests/test_hermes_webui_brand.py
@@ -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(