From b1bc66122ba5cb1e3000cded337a0b0ab4218b7f Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 24 Jul 2026 14:53:58 +0000 Subject: [PATCH] feat: improve adaptive accessibility safeguards --- CHANGELOG.md | 4 + PROJECT_PLAN.md | 4 +- .../android/app/src/main/AndroidManifest.xml | 2 +- .../molberg/hermesmobile/MainActivity.kt | 187 ++++++++++++++---- .../hermesmobile/chat/FakeChatScreen.kt | 90 ++++++--- .../hermesmobile/design/AdaptiveQuality.kt | 77 ++++++++ .../molberg/hermesmobile/design/Components.kt | 50 +++-- .../design/AdaptiveQualityTest.kt | 62 ++++++ docs/BLOCKERS.md | 6 + docs/ROADMAP.md | 14 ++ 10 files changed, 411 insertions(+), 85 deletions(-) create mode 100644 apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/design/AdaptiveQuality.kt create mode 100644 apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/design/AdaptiveQualityTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 352cd60..2d3d577 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du ## Unreleased ### Added +- Added deterministic small-phone, landscape, and tablet layout policy with navigation rail adaptation, tablet two-pane chat, and narrow/large-font control stacking. +- Added accessibility semantics for headings, panes, streaming announcements, selected/busy states, and actionable rows, plus 48dp minimum touch targets and bounded large message/code rendering with retained full-copy actions for code and tool output. - Added capability-gated direct gateway tool lifecycle decoding and compact status cards for `tool.started`, `tool.progress`, `tool.completed`, and `tool.failed` SSE events. - Added bounded collapsed/expanded large-output windows with full-output copy, plus durable expanded-tool restoration across rotation/process resume. - Added a durable direct-gateway SSE send coordinator that captures the selected session, draft, request ID, and immutable route lease before asynchronous execution. @@ -30,6 +32,7 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du - Added `PROJECT_PLAN.md` as the project-controlled milestone plan. ### Changed +- Removed the Android portrait lock, enabled `adjustResize` IME behavior, and suppressed top-level screen slides when the system animator duration scale is disabled. - Kept direct artifact handoff actions gated off because the advertised gateway contract has no verified artifact URI/download capability; no upstream route or backend behavior was inferred. - Changed direct session streaming to publish reducer updates to the owning durable send coordinator while retaining the original immutable route/session lease for the entire OkHttp call. - Changed direct session streaming so one immutable route/session lease owns the request and every decoded event for the full OkHttp call. @@ -55,6 +58,7 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du - Fixed companion/mobile lint issues from missing Node globals and an unused React settings value. ### Verification +- R1 source-level verification: the required recovery preflight passed immediately before this run; direct JUnit execution of the cached, source-current Android unit classes passed all 61 tests, including all 4 `AdaptiveQualityTest` cases; and `git diff --check` passed. `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew --offline --no-daemon -Dorg.gradle.jvmargs= -Dorg.gradle.daemon=false -Pkotlin.compiler.execution.strategy=in-process :app:compileDebugKotlin :app:compileDebugUnitTestKotlin :app:assembleDebug` was blocked before project execution by `java.net.SocketException: Operation not permitted`. ADB 37.0.0 is installed at `/opt/android-sdk/platform-tools/adb`, but its daemon cannot create the smart-socket or netlink sockets; no emulator executable, AVD, `/dev/kvm`, USB device bus, or running target exists. No emulator/device matrix evidence exists, so R1 remains incomplete. - Full Android Kotlin/Compose main-source and unit-test compilation through cached Kotlin 2.0.21 plus direct JUnit execution — passed, 57 tests including B5 large-output bounds, advertised tool lifecycle reduction, and rotation/resume expansion restoration. - Recovery preflight for B5 — passed before this run. - `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew --offline --no-daemon -Dorg.gradle.jvmargs= -Dorg.gradle.daemon=false -Pkotlin.compiler.execution.strategy=in-process :app:testDebugUnitTest :app:assembleDebug` — attempted for B5 but blocked before project execution because the sandbox prohibits Gradle's TCP control socket (`java.net.SocketException: Operation not permitted`). diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index 389cb1e..4c4dd7e 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -24,10 +24,12 @@ Complete the Android/client contract pivot before implementing backend behavior. The next release path targets a remote upstream Hermes Agent gateway/API server directly; `apps/companion` is legacy compatibility only. -- [ ] **R1 — Adaptive/accessibility quality pass** +- [ ] **R1 — Execute and document the adaptive/accessibility device matrix** - Small phone, normal phone, tablet/two-pane behavior; portrait/landscape. - Font scaling, TalkBack semantics, contrast, touch targets, reduced motion, IME behavior. - Verification: emulator/device matrix evidence documented in `docs/ROADMAP.md`. + - Source-level progress on 2026-07-24: removed the portrait lock, enabled resize-on-IME, added deterministic phone/landscape/tablet layout policy and tablet two-pane chat, stacked dense controls for narrow/large-font layouts, respected the disabled system animator-duration setting for top-level navigation, added headings/live regions/roles/state descriptions, raised interactive targets to at least 48dp, and bounded very large message/code rendering while retaining full-copy actions for code and tool output. + - Source verification on 2026-07-24: the required recovery preflight passed immediately before this run; direct JUnit execution of the cached, source-current Android unit classes passed all 61 tests, including all 4 `AdaptiveQualityTest` cases; and `git diff --check` passed. A direct writable-cache Gradle retry was blocked before project execution by `java.net.SocketException: Operation not permitted`. ADB is installed, but its daemon sockets are sandbox-prohibited; no emulator executable, AVD, `/dev/kvm`, USB device bus, or running target is available. R1 remains unchecked because no emulator/device matrix evidence exists. ## Externally blocked diff --git a/apps/mobile/android/app/src/main/AndroidManifest.xml b/apps/mobile/android/app/src/main/AndroidManifest.xml index 4ef4b77..828517a 100644 --- a/apps/mobile/android/app/src/main/AndroidManifest.xml +++ b/apps/mobile/android/app/src/main/AndroidManifest.xml @@ -15,7 +15,7 @@ + android:windowSoftInputMode="adjustResize"> diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt index f503f9f..abdf884 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt @@ -9,6 +9,7 @@ import android.os.Bundle import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager +import android.provider.Settings import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent @@ -22,17 +23,20 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape @@ -74,12 +78,15 @@ import cloud.molberg.hermesmobile.design.SecondaryButton import cloud.molberg.hermesmobile.design.SectionHeader import cloud.molberg.hermesmobile.design.TinyLabel import cloud.molberg.hermesmobile.design.TypingIndicator +import cloud.molberg.hermesmobile.design.adaptiveLayout import cloud.molberg.hermesmobile.chat.FakeInboxScreen import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost @@ -101,6 +108,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow @@ -309,50 +321,108 @@ fun HermesNativeApp() { GatewayReevaluationHooks(api) { connectionState = it } HermesMobileTheme { - Scaffold( - modifier = Modifier.fillMaxSize(), - containerColor = HermesTheme.colors.background, - topBar = { - NativeTopBar(screen, connectionState.connected, onAction = { - if (screen == Screen.Inbox) { - resetInboxToken++ - notify("New chat started.") + BoxWithConstraints(Modifier.fillMaxSize()) { + val fontScale = LocalDensity.current.fontScale + val layout = adaptiveLayout(maxWidth.value.toInt(), maxHeight.value.toInt(), fontScale) + val reduceMotion = rememberReducedMotion() + val selectScreen: (Screen) -> Unit = { + if (it != screen) vibrate(context) + screen = it + } + Row(Modifier.fillMaxSize()) { + if (layout.useSideNavigation) { + NativeSideBar(screen, selectScreen) + } + Scaffold( + modifier = Modifier.weight(1f), + containerColor = HermesTheme.colors.background, + topBar = { + NativeTopBar(screen, connectionState.connected, onAction = { + if (screen == Screen.Inbox) { + resetInboxToken++ + notify("New chat started.") + } + }) + }, + bottomBar = { + if (!layout.useSideNavigation) NativeBottomBar(screen, selectScreen) + }, + snackbarHost = { SnackbarHost(snackbar) } + ) { padding -> + val contentModifier = Modifier + .fillMaxSize() + .padding(padding) + if (reduceMotion) { + NativeScreenContent( + screen = screen, + api = api, + connectionState = connectionState, + onConnectionState = { connectionState = it }, + resetInboxToken = resetInboxToken, + onNewChat = { + resetInboxToken++ + selectScreen(Screen.Inbox) + notify("New chat started.") + }, + notify = ::notify, + modifier = contentModifier + ) + } else { + AnimatedContent( + targetState = screen, + transitionSpec = { slideFor(initialState.ordinal, targetState.ordinal) }, + modifier = contentModifier, + label = "screen-slide" + ) { active -> + NativeScreenContent( + screen = active, + api = api, + connectionState = connectionState, + onConnectionState = { connectionState = it }, + resetInboxToken = resetInboxToken, + onNewChat = { + resetInboxToken++ + selectScreen(Screen.Inbox) + notify("New chat started.") + }, + notify = ::notify + ) + } } - }) - }, - bottomBar = { - NativeBottomBar( - current = screen, - onSelect = { - if (it != screen) vibrate(context) - screen = it - } - ) - }, - snackbarHost = { SnackbarHost(snackbar) } - ) { padding -> - AnimatedContent( - targetState = screen, - transitionSpec = { slideFor(initialState.ordinal, targetState.ordinal) }, - modifier = Modifier - .fillMaxSize() - .padding(padding), - label = "screen-slide" - ) { active -> - when (active) { - Screen.Inbox -> FakeInboxScreen(resetInboxToken, ::notify) - Screen.Terminals -> TerminalsScreen(api, ::notify, onNewChat = { - resetInboxToken++ - screen = Screen.Inbox - notify("New chat started.") - }) - Screen.Settings -> SettingsScreen(api, connectionState, { connectionState = it }, ::notify) } } } } } +@Composable +private fun NativeScreenContent( + screen: Screen, + api: CompanionApi, + connectionState: ConnectionUiState, + onConnectionState: (ConnectionUiState) -> Unit, + resetInboxToken: Int, + onNewChat: () -> Unit, + notify: (String) -> Unit, + modifier: Modifier = Modifier +) { + Box(modifier) { + when (screen) { + Screen.Inbox -> FakeInboxScreen(resetInboxToken, notify) + Screen.Terminals -> TerminalsScreen(api, notify, onNewChat = onNewChat) + Screen.Settings -> SettingsScreen(api, connectionState, onConnectionState, notify) + } + } +} + +@Composable +private fun rememberReducedMotion(): Boolean { + val context = LocalContext.current + return remember(context) { + Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f + } +} + @Composable private fun GatewayReevaluationHooks(api: CompanionApi, onState: (ConnectionUiState) -> Unit) { val context = LocalContext.current @@ -418,15 +488,19 @@ fun NativeTopBar(screen: Screen, online: Boolean, onAction: () -> Unit) { horizontalArrangement = Arrangement.spacedBy(12.dp) ) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(screen.title, color = HermesTheme.colors.text, style = HermesTheme.typography.screenTitle) - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(screen.title, color = HermesTheme.colors.text, style = HermesTheme.typography.screenTitle, modifier = Modifier.semantics { heading() }) + Row( + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { val statusColor = if (online) HermesTheme.status.success else HermesTheme.colors.textMuted Box(Modifier.size(8.dp).clip(CircleShape).background(statusColor)) Text(if (online) "connected" else "offline", color = statusColor, style = HermesTheme.typography.caption, fontWeight = FontWeight.SemiBold) } } if (screen == Screen.Inbox) { - IconButton(onClick = onAction, modifier = Modifier.size(44.dp)) { + IconButton(onClick = onAction, modifier = Modifier.size(48.dp)) { Icon(Icons.Filled.Add, contentDescription = "New chat", tint = HermesTheme.colors.text) } } @@ -440,7 +514,7 @@ fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) { modifier = Modifier .fillMaxWidth() .navigationBarsPadding() - .height(76.dp), + .heightIn(min = 72.dp), containerColor = HermesTheme.colors.background, tonalElevation = HermesTheme.elevation.none ) { @@ -463,6 +537,35 @@ fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) { } } +@Composable +fun NativeSideBar(current: Screen, onSelect: (Screen) -> Unit) { + Surface(color = HermesTheme.colors.background, tonalElevation = HermesTheme.elevation.none) { + NavigationRail( + modifier = Modifier + .width(88.dp) + .statusBarsPadding() + .navigationBarsPadding(), + containerColor = HermesTheme.colors.background + ) { + Screen.entries.forEach { item -> + NavigationRailItem( + selected = current == item, + onClick = { onSelect(item) }, + icon = { Icon(item.icon, contentDescription = item.label) }, + label = { Text(item.label, fontWeight = FontWeight.SemiBold) }, + colors = androidx.compose.material3.NavigationRailItemDefaults.colors( + selectedIconColor = HermesTheme.colors.text, + selectedTextColor = HermesTheme.colors.text, + indicatorColor = HermesTheme.colors.surfaceRaised, + unselectedIconColor = HermesTheme.colors.textMuted, + unselectedTextColor = HermesTheme.colors.textMuted + ) + ) + } + } + } +} + @Composable fun InboxScreen(api: CompanionApi, resetToken: Int) { val scope = remember { CoroutineScope(Dispatchers.Main) } @@ -562,7 +665,7 @@ fun ConversationThread( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp) ) { - IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) { + IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) { Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text) } Column(Modifier.weight(1f)) { diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt index b53862a..3a43ebc 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -56,6 +57,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.paneTitle +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -63,6 +74,7 @@ import androidx.compose.ui.unit.dp import cloud.molberg.hermesmobile.design.EmptyState import cloud.molberg.hermesmobile.design.FlatCard import cloud.molberg.hermesmobile.design.HermesTheme +import cloud.molberg.hermesmobile.design.LongTextPolicy import cloud.molberg.hermesmobile.design.ModeChip import cloud.molberg.hermesmobile.design.NoticeCard import cloud.molberg.hermesmobile.design.PrimaryButton @@ -70,6 +82,7 @@ import cloud.molberg.hermesmobile.design.ScreenList import cloud.molberg.hermesmobile.design.SecondaryButton import cloud.molberg.hermesmobile.design.SectionHeader import cloud.molberg.hermesmobile.design.TinyLabel +import cloud.molberg.hermesmobile.design.adaptiveLayout import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -154,7 +167,8 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) { } BoxWithConstraints(Modifier.fillMaxSize()) { - val wide = maxWidth >= 720.dp + val layout = adaptiveLayout(maxWidth.value.toInt(), maxHeight.value.toInt(), LocalDensity.current.fontScale) + val wide = layout.useTwoPaneChat val selected = state.selectedSession if (!wide) { BackHandler(selected != null) { state = state.copy(selectedSessionId = null) } @@ -165,7 +179,7 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) { ChatSessionRail( state = state, modifier = Modifier - .width(304.dp) + .width(layout.sessionPaneWidthDp.dp) .fillMaxHeight(), onNew = ::createSession, onSelect = ::selectSession, @@ -273,7 +287,13 @@ private fun SessionRow(session: ChatSessionUi, selected: Boolean, onClick: () -> ) { Row( Modifier - .clickable(onClick = onClick) + .clickable(role = Role.Button, onClick = onClick) + .semantics(mergeDescendants = true) { + role = Role.Button + this.selected = selected + stateDescription = if (session.busy) "Busy" else "Ready" + } + .heightIn(min = 48.dp) .padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) @@ -334,7 +354,7 @@ private fun ChatConversationPane( ScreenList { EmptyState() } return } - Column(modifier) { + Column(modifier.semantics { paneTitle = "Conversation" }) { ConversationHeader(session, state.connectionState, onBack) LazyColumn( modifier = Modifier.weight(1f), @@ -384,7 +404,7 @@ private fun ConversationHeader(session: ChatSessionUi, connectionState: ChatConn horizontalArrangement = Arrangement.spacedBy(6.dp) ) { if (onBack != null) { - IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) { + IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) { Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text) } } @@ -403,20 +423,33 @@ private fun ConversationHeader(session: ChatSessionUi, connectionState: ChatConn @Composable private fun ContextControlsCard(context: ChatContextControls, onContext: (ChatContextControls) -> Unit) { - FlatCard { - Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + BoxWithConstraints { + val stackControls = adaptiveLayout(maxWidth.value.toInt(), maxHeight.value.toInt(), LocalDensity.current.fontScale).stackDenseControls + FlatCard { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { Icon(Icons.Filled.Tune, null, tint = HermesTheme.colors.textMuted, modifier = Modifier.size(18.dp)) Text("Context", color = HermesTheme.colors.text, style = HermesTheme.typography.rowTitle) Spacer(Modifier.weight(1f)) - Switch(checked = context.toolsEnabled, onCheckedChange = { onContext(context.copy(toolsEnabled = it)) }) + Switch( + checked = context.toolsEnabled, + onCheckedChange = { onContext(context.copy(toolsEnabled = it)) }, + modifier = Modifier.semantics { contentDescription = "Tools enabled" } + ) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { - ModeChip("Default", context.model == "Hermes default", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes default")) } - ModeChip("Fast", context.model == "Hermes fast", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes fast")) } - ModeChip("Deep", context.effort == "High", Modifier.weight(1f)) { onContext(context.copy(effort = if (context.effort == "High") "Normal" else "High")) } + if (stackControls) { + ModeChip("Default", context.model == "Hermes default", Modifier.fillMaxWidth()) { onContext(context.copy(model = "Hermes default")) } + ModeChip("Fast", context.model == "Hermes fast", Modifier.fillMaxWidth()) { onContext(context.copy(model = "Hermes fast")) } + ModeChip("Deep", context.effort == "High", Modifier.fillMaxWidth()) { onContext(context.copy(effort = if (context.effort == "High") "Normal" else "High")) } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + ModeChip("Default", context.model == "Hermes default", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes default")) } + ModeChip("Fast", context.model == "Hermes fast", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes fast")) } + ModeChip("Deep", context.effort == "High", Modifier.weight(1f)) { onContext(context.copy(effort = if (context.effort == "High") "Normal" else "High")) } + } } Text(context.workspace, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, maxLines = 1, overflow = TextOverflow.Ellipsis) + } } } } @@ -431,7 +464,9 @@ private fun ChatMessageCard( val isUser = message.role == ChatRole.User Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start) { FlatCard( - modifier = Modifier.fillMaxWidth(if (isUser) 0.86f else 0.94f), + modifier = Modifier + .fillMaxWidth(if (isUser) 0.86f else 0.94f) + .semantics { if (message.streaming) liveRegion = LiveRegionMode.Polite }, color = when (message.role) { ChatRole.User -> HermesTheme.colors.userBubble ChatRole.Error -> HermesTheme.status.dangerPanel @@ -448,7 +483,7 @@ private fun ChatMessageCard( } Spacer(Modifier.weight(1f)) if (message.retryPrompt != null) { - IconButton(onClick = onRetry, modifier = Modifier.size(38.dp)) { + IconButton(onClick = onRetry, modifier = Modifier.size(48.dp)) { Icon(Icons.Filled.Refresh, contentDescription = "Retry", tint = HermesTheme.colors.text) } } @@ -467,18 +502,19 @@ private fun ChatMessageCard( @Composable private fun MarkdownText(text: String) { + val display = LongTextPolicy.message(text.replace("**", "")) SelectionContainer { - Text( - text = text.replace("**", ""), - color = HermesTheme.colors.text, - style = HermesTheme.typography.body - ) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(text = display.text, color = HermesTheme.colors.text, style = HermesTheme.typography.body) + if (display.truncated) Text("Long response capped for safe display.", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption) + } } } @Composable private fun CodeBlockCard(language: String, code: String, notify: (String) -> Unit) { val clipboard = LocalClipboardManager.current + val display = LongTextPolicy.code(code) FlatCard(color = HermesTheme.colors.surfaceRaised) { Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { @@ -489,14 +525,14 @@ private fun CodeBlockCard(language: String, code: String, notify: (String) -> Un clipboard.setText(AnnotatedString(code)) notify("Code copied.") }, - modifier = Modifier.size(36.dp) + modifier = Modifier.size(48.dp) ) { Icon(Icons.Filled.ContentCopy, contentDescription = "Copy code", tint = HermesTheme.colors.text) } } SelectionContainer { Text( - code, + display.text, modifier = Modifier .horizontalScroll(rememberScrollState()) .padding(bottom = 2.dp), @@ -504,6 +540,7 @@ private fun CodeBlockCard(language: String, code: String, notify: (String) -> Un style = HermesTheme.typography.code ) } + if (display.truncated) Text("Large code capped for safe display. Copy includes the full code.", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption) } } } @@ -517,7 +554,12 @@ private fun ToolResultCard(result: ToolResultUi, onToggle: () -> Unit, notify: ( Row( Modifier .fillMaxWidth() - .clickable(onClick = onToggle), + .clickable(role = Role.Button, onClick = onToggle) + .semantics { + role = Role.Button + stateDescription = if (result.expanded) "Expanded" else "Collapsed" + } + .heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { @@ -529,7 +571,7 @@ private fun ToolResultCard(result: ToolResultUi, onToggle: () -> Unit, notify: ( clipboard.setText(AnnotatedString(result.output)) notify("Tool output copied.") }, - modifier = Modifier.size(36.dp) + modifier = Modifier.size(48.dp) ) { Icon(Icons.Filled.ContentCopy, contentDescription = "Copy tool output", tint = HermesTheme.colors.textMuted) } @@ -581,7 +623,7 @@ private fun ChatComposer( value = session.draft, onValueChange = onDraft, modifier = Modifier.weight(1f), - placeholder = { Text("Message Hermes", color = HermesTheme.colors.textMuted) }, + label = { Text("Message Hermes") }, minLines = 1, maxLines = 5, shape = RoundedCornerShape(24.dp), diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/design/AdaptiveQuality.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/design/AdaptiveQuality.kt new file mode 100644 index 0000000..2485e83 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/design/AdaptiveQuality.kt @@ -0,0 +1,77 @@ +package cloud.molberg.hermesmobile.design + +import androidx.compose.ui.graphics.Color +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +enum class HermesWindowSize { Small, Normal, Tablet } + +data class AdaptiveLayout( + val windowSize: HermesWindowSize, + val landscape: Boolean, + val useSideNavigation: Boolean, + val useTwoPaneChat: Boolean, + val stackDenseControls: Boolean, + val sessionPaneWidthDp: Int +) + +fun adaptiveLayout(widthDp: Int, heightDp: Int, fontScale: Float): AdaptiveLayout { + val windowSize = when { + widthDp < 360 -> HermesWindowSize.Small + widthDp < 720 -> HermesWindowSize.Normal + else -> HermesWindowSize.Tablet + } + val landscape = widthDp > heightDp + return AdaptiveLayout( + windowSize = windowSize, + landscape = landscape, + useSideNavigation = widthDp >= 600 && (landscape || windowSize == HermesWindowSize.Tablet), + useTwoPaneChat = widthDp >= 720, + stackDenseControls = widthDp < 400 || fontScale >= 1.3f, + sessionPaneWidthDp = when { + widthDp >= 1000 -> 336 + widthDp >= 840 -> 312 + else -> 288 + } + ) +} + +data class SafeTextWindow(val text: String, val truncated: Boolean) + +object LongTextPolicy { + const val MaxMessageCharacters = 48_000 + const val MaxCodeCharacters = 24_000 + const val MaxCodeLines = 120 + + fun message(text: String): SafeTextWindow = characterWindow(text, MaxMessageCharacters) + + fun code(text: String): SafeTextWindow { + val lines = text.lineSequence().take(MaxCodeLines + 1).toList() + val lineLimited = lines.size > MaxCodeLines + val visible = if (lineLimited) lines.take(MaxCodeLines).joinToString("\n") else text + val characterLimited = visible.length > MaxCodeCharacters + return SafeTextWindow( + text = if (characterLimited) visible.take(MaxCodeCharacters) else visible, + truncated = lineLimited || characterLimited + ) + } + + private fun characterWindow(text: String, limit: Int) = SafeTextWindow( + text = if (text.length > limit) text.take(limit) else text, + truncated = text.length > limit + ) +} + +fun contrastRatio(first: Color, second: Color): Double { + fun luminance(color: Color): Double { + fun channel(value: Float): Double { + val normalized = value.toDouble() + return if (normalized <= 0.03928) normalized / 12.92 else ((normalized + 0.055) / 1.055).pow(2.4) + } + return 0.2126 * channel(color.red) + 0.7152 * channel(color.green) + 0.0722 * channel(color.blue) + } + val firstLuminance = luminance(first) + val secondLuminance = luminance(second) + return (max(firstLuminance, secondLuminance) + 0.05) / (min(firstLuminance, secondLuminance) + 0.05) +} diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/design/Components.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/design/Components.kt index be92556..4bfc1f3 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/design/Components.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/design/Components.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -46,6 +47,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -68,7 +74,7 @@ fun ScreenList(content: @Composable ColumnScope.() -> Unit) { fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnScope.() -> Unit) { ScreenList { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm), modifier = Modifier.fillMaxWidth()) { - androidx.compose.material3.IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) { + androidx.compose.material3.IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back", tint = HermesTheme.colors.text) } Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.panelTitle) @@ -80,7 +86,7 @@ fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnSco @Composable fun SectionHeader(title: String, detail: String) { Column(Modifier.padding(top = HermesTheme.spacing.sm, start = HermesTheme.spacing.xxs, end = HermesTheme.spacing.xxs), verticalArrangement = Arrangement.spacedBy(3.dp)) { - Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.sectionTitle) + Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.sectionTitle, modifier = Modifier.semantics { heading() }) Text(detail, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption) } } @@ -106,7 +112,9 @@ fun ListRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> U FlatCard { Row( Modifier - .clickable(onClick = onClick) + .clickable(role = Role.Button, onClick = onClick) + .semantics(mergeDescendants = true) { role = Role.Button } + .heightIn(min = 48.dp) .padding(horizontal = 14.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md) @@ -133,13 +141,17 @@ fun ListRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> U fun ModeChip(text: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) { Surface( modifier = modifier - .height(44.dp) + .heightIn(min = 48.dp) .clip(RoundedCornerShape(HermesTheme.shapes.chip)) - .clickable(onClick = onClick), + .clickable(role = Role.Button, onClick = onClick) + .semantics { + role = Role.Button + this.selected = selected + }, color = if (selected) HermesTheme.colors.primary else HermesTheme.colors.surfaceRaised, contentColor = if (selected) HermesTheme.colors.onPrimary else HermesTheme.colors.text ) { - Box(contentAlignment = Alignment.Center) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) { Text(text, fontWeight = FontWeight.SemiBold, style = HermesTheme.typography.body) } } @@ -249,15 +261,17 @@ fun NoticeCard(text: String, danger: Boolean = false) { @Composable fun CodeCard(text: String) { + val display = LongTextPolicy.code(text) FlatCard { - Text( - text, - Modifier - .horizontalScroll(rememberScrollState()) - .padding(14.dp), - color = HermesTheme.colors.codeText, - style = HermesTheme.typography.code - ) + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + display.text, + Modifier.horizontalScroll(rememberScrollState()), + color = HermesTheme.colors.codeText, + style = HermesTheme.typography.code + ) + if (display.truncated) Text("Large code capped for safe display.", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption) + } } } @@ -266,7 +280,9 @@ fun FileRow(name: String, type: String, size: Long, onClick: () -> Unit) { FlatCard { Row( Modifier - .clickable(onClick = onClick) + .clickable(role = Role.Button, onClick = onClick) + .semantics(mergeDescendants = true) { role = Role.Button } + .heightIn(min = 48.dp) .padding(10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp) @@ -343,7 +359,7 @@ fun PrimaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier Button( onClick = onClick, enabled = enabled, - modifier = modifier.height(52.dp), + modifier = modifier.heightIn(min = 52.dp), shape = RoundedCornerShape(HermesTheme.shapes.input), colors = ButtonDefaults.buttonColors( containerColor = HermesTheme.colors.primary, @@ -364,7 +380,7 @@ fun SecondaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifi Button( onClick = onClick, enabled = enabled, - modifier = modifier.height(52.dp), + modifier = modifier.heightIn(min = 52.dp), shape = RoundedCornerShape(HermesTheme.shapes.input), colors = ButtonDefaults.buttonColors( containerColor = HermesTheme.colors.surface, diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/design/AdaptiveQualityTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/design/AdaptiveQualityTest.kt new file mode 100644 index 0000000..7fd151c --- /dev/null +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/design/AdaptiveQualityTest.kt @@ -0,0 +1,62 @@ +package cloud.molberg.hermesmobile.design + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AdaptiveQualityTest { + @Test + fun classifiesSmallNormalLandscapeAndTabletLayouts() { + val small = adaptiveLayout(320, 640, 1f) + val normal = adaptiveLayout(393, 852, 1f) + val landscape = adaptiveLayout(700, 393, 1f) + val tablet = adaptiveLayout(840, 900, 1f) + + assertEquals(HermesWindowSize.Small, small.windowSize) + assertFalse(small.useSideNavigation) + assertEquals(HermesWindowSize.Normal, normal.windowSize) + assertTrue(landscape.landscape) + assertTrue(landscape.useSideNavigation) + assertFalse(landscape.useTwoPaneChat) + assertEquals(HermesWindowSize.Tablet, tablet.windowSize) + assertTrue(tablet.useTwoPaneChat) + assertEquals(312, tablet.sessionPaneWidthDp) + } + + @Test + fun stacksDenseControlsForSmallWidthOrLargeFonts() { + assertTrue(adaptiveLayout(359, 780, 1f).stackDenseControls) + assertTrue(adaptiveLayout(500, 780, 1.5f).stackDenseControls) + assertFalse(adaptiveLayout(500, 780, 1f).stackDenseControls) + } + + @Test + fun longTextWindowsAreDeterministicAndBounded() { + val message = LongTextPolicy.message("x".repeat(LongTextPolicy.MaxMessageCharacters + 1)) + val code = LongTextPolicy.code((1..130).joinToString("\n") { "line $it" }) + + assertEquals(LongTextPolicy.MaxMessageCharacters, message.text.length) + assertTrue(message.truncated) + assertEquals(LongTextPolicy.MaxCodeLines, code.text.lineSequence().count()) + assertTrue(code.truncated) + } + + @Test + fun textAndStatusPairsMeetNormalTextContrast() { + val pairs = listOf( + lightHermesColors.text to lightHermesColors.background, + lightHermesColors.textMuted to lightHermesColors.background, + lightHermesColors.codeText to lightHermesColors.surfaceRaised, + darkHermesColors.text to darkHermesColors.background, + darkHermesColors.textMuted to darkHermesColors.background, + darkHermesColors.codeText to darkHermesColors.surfaceRaised, + lightHermesStatusColors.danger to lightHermesStatusColors.dangerPanel, + darkHermesStatusColors.danger to darkHermesStatusColors.dangerPanel + ) + + pairs.forEach { (foreground, background) -> + assertTrue("contrast was ${contrastRatio(foreground, background)}", contrastRatio(foreground, background) >= 4.5) + } + } +} diff --git a/docs/BLOCKERS.md b/docs/BLOCKERS.md index 010c91d..ada838a 100644 --- a/docs/BLOCKERS.md +++ b/docs/BLOCKERS.md @@ -1,5 +1,11 @@ # Hermes Mobile Blockers +## R1 — Emulator/device accessibility matrix + +R1 cannot be marked complete from the current sandbox. On 2026-07-24, the required recovery preflight passed immediately before this run, but a direct writable-cache Gradle retry was blocked before project execution because the sandbox prohibits Gradle's TCP control socket (`java.net.SocketException: Operation not permitted`). ADB 37.0.0 is installed at `/opt/android-sdk/platform-tools/adb`, but its daemon cannot create the local smart-socket listener or netlink socket (`Operation not permitted`). No emulator executable, AVD, `/dev/kvm`, USB device bus, or running target is available. No successful attached-target query, physical-device walkthrough, emulator walkthrough, or device evidence was possible. + +Required unblock evidence: run the required preflight and focused Android tests in an environment that permits them, then record the small-phone, normal-phone, and tablet checks in `docs/ROADMAP.md` across portrait/landscape, font scaling, TalkBack, light/dark contrast, touch targets, reduced motion, and IME behavior. Source review, `git diff --check`, and direct execution of cached test classes are supporting evidence only and do not complete R1. + ## Stable gateway identity binding The documented direct gateway routes currently available to Hermes Mobile (`GET /health` and authenticated `GET /v1/models`) do not expose a verified stable gateway instance identifier. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b3c932d..f3cc2aa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -26,6 +26,20 @@ Remaining for beta: - Confirm adaptive accessibility: font scaling, TalkBack labels, contrast, keyboard/IME behavior, and reduced-motion tolerance. - Document beta install/run steps after the native build is verified. +## R1 Adaptive/Accessibility Matrix + +Source-level safeguards were added and reviewed on 2026-07-24, but this table is **not device evidence**. R1 remains incomplete until each row is exercised and recorded on an emulator or physical device. + +| Configuration | Required checks | Status on 2026-07-24 | +| --- | --- | --- | +| Small phone, portrait and landscape | Navigation, chat list/detail, composer with IME, 200% font scale | Blocked — ADB daemon sockets are prohibited and no emulator executable is installed | +| Normal phone, portrait and landscape | Bottom navigation, rotation continuity, long messages/code, light/dark contrast | Blocked — ADB daemon sockets are prohibited and no emulator executable is installed | +| Tablet, portrait and landscape | Navigation rail, two-pane sessions/chat, pane focus order, resizing | Blocked — ADB daemon sockets are prohibited and no emulator executable is installed | +| Accessibility services | TalkBack labels/order/announcements, switch and selected-state descriptions, 48dp targets | Blocked — requires emulator/device accessibility service walkthrough | +| Motion and keyboard | Animator duration scale off, IME resize/insets, send/cancel visibility | Blocked — requires emulator/device settings and keyboard interaction | + +Source-level evidence without device verification: orientation is no longer manifest-locked; the activity uses `adjustResize`; the required recovery preflight passed immediately before this run; `git diff --check` passed; and direct JUnit execution of the cached, source-current Android unit classes passed all 61 tests, including all 4 `AdaptiveQualityTest` cases covering adaptive thresholds, dense-control stacking, long-text bounds, and selected light/dark contrast pairs. A direct writable-cache Gradle retry was blocked before project execution by `java.net.SocketException: Operation not permitted`. ADB 37.0.0 exists at `/opt/android-sdk/platform-tools/adb`, but its daemon cannot create the sandbox-prohibited smart-socket/netlink sockets. No emulator executable, AVD, `/dev/kvm`, USB device bus, or running target is available. + ## Beta Release Criteria - Native Kotlin/Compose Android app is the shipped beta client.