feat: improve adaptive accessibility safeguards

This commit is contained in:
Hermes Agent
2026-07-24 14:53:58 +00:00
parent 1c33d497fd
commit b1bc66122b
10 changed files with 411 additions and 85 deletions
+4
View File
@@ -5,6 +5,8 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
## Unreleased ## Unreleased
### Added ### 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 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 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. - 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. - Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
### Changed ### 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. - 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 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. - 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. - Fixed companion/mobile lint issues from missing Node globals and an unused React settings value.
### Verification ### 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. - 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. - 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`). - `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`).
+3 -1
View File
@@ -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. 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. - Small phone, normal phone, tablet/two-pane behavior; portrait/landscape.
- Font scaling, TalkBack semantics, contrast, touch targets, reduced motion, IME behavior. - Font scaling, TalkBack semantics, contrast, touch targets, reduced motion, IME behavior.
- Verification: emulator/device matrix evidence documented in `docs/ROADMAP.md`. - 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 ## Externally blocked
@@ -15,7 +15,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:screenOrientation="portrait"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
@@ -9,6 +9,7 @@ import android.os.Bundle
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator import android.os.Vibrator
import android.os.VibratorManager import android.os.VibratorManager
import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@@ -22,17 +23,20 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape 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.SectionHeader
import cloud.molberg.hermesmobile.design.TinyLabel import cloud.molberg.hermesmobile.design.TinyLabel
import cloud.molberg.hermesmobile.design.TypingIndicator import cloud.molberg.hermesmobile.design.TypingIndicator
import cloud.molberg.hermesmobile.design.adaptiveLayout
import cloud.molberg.hermesmobile.chat.FakeInboxScreen import cloud.molberg.hermesmobile.chat.FakeInboxScreen
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost 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.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext 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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -309,8 +321,20 @@ fun HermesNativeApp() {
GatewayReevaluationHooks(api) { connectionState = it } GatewayReevaluationHooks(api) { connectionState = it }
HermesMobileTheme { HermesMobileTheme {
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( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.weight(1f),
containerColor = HermesTheme.colors.background, containerColor = HermesTheme.colors.background,
topBar = { topBar = {
NativeTopBar(screen, connectionState.connected, onAction = { NativeTopBar(screen, connectionState.connected, onAction = {
@@ -321,37 +345,83 @@ fun HermesNativeApp() {
}) })
}, },
bottomBar = { bottomBar = {
NativeBottomBar( if (!layout.useSideNavigation) NativeBottomBar(screen, selectScreen)
current = screen,
onSelect = {
if (it != screen) vibrate(context)
screen = it
}
)
}, },
snackbarHost = { SnackbarHost(snackbar) } snackbarHost = { SnackbarHost(snackbar) }
) { padding -> ) { 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( AnimatedContent(
targetState = screen, targetState = screen,
transitionSpec = { slideFor(initialState.ordinal, targetState.ordinal) }, transitionSpec = { slideFor(initialState.ordinal, targetState.ordinal) },
modifier = Modifier modifier = contentModifier,
.fillMaxSize()
.padding(padding),
label = "screen-slide" label = "screen-slide"
) { active -> ) { active ->
when (active) { NativeScreenContent(
Screen.Inbox -> FakeInboxScreen(resetInboxToken, ::notify) screen = active,
Screen.Terminals -> TerminalsScreen(api, ::notify, onNewChat = { api = api,
connectionState = connectionState,
onConnectionState = { connectionState = it },
resetInboxToken = resetInboxToken,
onNewChat = {
resetInboxToken++ resetInboxToken++
screen = Screen.Inbox selectScreen(Screen.Inbox)
notify("New chat started.") notify("New chat started.")
}) },
Screen.Settings -> SettingsScreen(api, connectionState, { connectionState = it }, ::notify) notify = ::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 @Composable
private fun GatewayReevaluationHooks(api: CompanionApi, onState: (ConnectionUiState) -> Unit) { private fun GatewayReevaluationHooks(api: CompanionApi, onState: (ConnectionUiState) -> Unit) {
@@ -418,15 +488,19 @@ fun NativeTopBar(screen: Screen, online: Boolean, onAction: () -> Unit) {
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(screen.title, color = HermesTheme.colors.text, style = HermesTheme.typography.screenTitle) Text(screen.title, color = HermesTheme.colors.text, style = HermesTheme.typography.screenTitle, modifier = Modifier.semantics { heading() })
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { 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 val statusColor = if (online) HermesTheme.status.success else HermesTheme.colors.textMuted
Box(Modifier.size(8.dp).clip(CircleShape).background(statusColor)) Box(Modifier.size(8.dp).clip(CircleShape).background(statusColor))
Text(if (online) "connected" else "offline", color = statusColor, style = HermesTheme.typography.caption, fontWeight = FontWeight.SemiBold) Text(if (online) "connected" else "offline", color = statusColor, style = HermesTheme.typography.caption, fontWeight = FontWeight.SemiBold)
} }
} }
if (screen == Screen.Inbox) { 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) Icon(Icons.Filled.Add, contentDescription = "New chat", tint = HermesTheme.colors.text)
} }
} }
@@ -440,7 +514,7 @@ fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.navigationBarsPadding() .navigationBarsPadding()
.height(76.dp), .heightIn(min = 72.dp),
containerColor = HermesTheme.colors.background, containerColor = HermesTheme.colors.background,
tonalElevation = HermesTheme.elevation.none 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 @Composable
fun InboxScreen(api: CompanionApi, resetToken: Int) { fun InboxScreen(api: CompanionApi, resetToken: Int) {
val scope = remember { CoroutineScope(Dispatchers.Main) } val scope = remember { CoroutineScope(Dispatchers.Main) }
@@ -562,7 +665,7 @@ fun ConversationThread(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp) 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) Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text)
} }
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext 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.AnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow 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.EmptyState
import cloud.molberg.hermesmobile.design.FlatCard import cloud.molberg.hermesmobile.design.FlatCard
import cloud.molberg.hermesmobile.design.HermesTheme import cloud.molberg.hermesmobile.design.HermesTheme
import cloud.molberg.hermesmobile.design.LongTextPolicy
import cloud.molberg.hermesmobile.design.ModeChip import cloud.molberg.hermesmobile.design.ModeChip
import cloud.molberg.hermesmobile.design.NoticeCard import cloud.molberg.hermesmobile.design.NoticeCard
import cloud.molberg.hermesmobile.design.PrimaryButton 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.SecondaryButton
import cloud.molberg.hermesmobile.design.SectionHeader import cloud.molberg.hermesmobile.design.SectionHeader
import cloud.molberg.hermesmobile.design.TinyLabel import cloud.molberg.hermesmobile.design.TinyLabel
import cloud.molberg.hermesmobile.design.adaptiveLayout
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -154,7 +167,8 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
} }
BoxWithConstraints(Modifier.fillMaxSize()) { 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 val selected = state.selectedSession
if (!wide) { if (!wide) {
BackHandler(selected != null) { state = state.copy(selectedSessionId = null) } BackHandler(selected != null) { state = state.copy(selectedSessionId = null) }
@@ -165,7 +179,7 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
ChatSessionRail( ChatSessionRail(
state = state, state = state,
modifier = Modifier modifier = Modifier
.width(304.dp) .width(layout.sessionPaneWidthDp.dp)
.fillMaxHeight(), .fillMaxHeight(),
onNew = ::createSession, onNew = ::createSession,
onSelect = ::selectSession, onSelect = ::selectSession,
@@ -273,7 +287,13 @@ private fun SessionRow(session: ChatSessionUi, selected: Boolean, onClick: () ->
) { ) {
Row( Row(
Modifier 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), .padding(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
@@ -334,7 +354,7 @@ private fun ChatConversationPane(
ScreenList { EmptyState() } ScreenList { EmptyState() }
return return
} }
Column(modifier) { Column(modifier.semantics { paneTitle = "Conversation" }) {
ConversationHeader(session, state.connectionState, onBack) ConversationHeader(session, state.connectionState, onBack)
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -384,7 +404,7 @@ private fun ConversationHeader(session: ChatSessionUi, connectionState: ChatConn
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
if (onBack != null) { 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) Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text)
} }
} }
@@ -403,23 +423,36 @@ private fun ConversationHeader(session: ChatSessionUi, connectionState: ChatConn
@Composable @Composable
private fun ContextControlsCard(context: ChatContextControls, onContext: (ChatContextControls) -> Unit) { private fun ContextControlsCard(context: ChatContextControls, onContext: (ChatContextControls) -> Unit) {
BoxWithConstraints {
val stackControls = adaptiveLayout(maxWidth.value.toInt(), maxHeight.value.toInt(), LocalDensity.current.fontScale).stackDenseControls
FlatCard { FlatCard {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { 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)) Icon(Icons.Filled.Tune, null, tint = HermesTheme.colors.textMuted, modifier = Modifier.size(18.dp))
Text("Context", color = HermesTheme.colors.text, style = HermesTheme.typography.rowTitle) Text("Context", color = HermesTheme.colors.text, style = HermesTheme.typography.rowTitle)
Spacer(Modifier.weight(1f)) 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" }
)
} }
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()) { 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("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("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")) } 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) Text(context.workspace, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
} }
} }
}
@Composable @Composable
private fun ChatMessageCard( private fun ChatMessageCard(
@@ -431,7 +464,9 @@ private fun ChatMessageCard(
val isUser = message.role == ChatRole.User val isUser = message.role == ChatRole.User
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start) { Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start) {
FlatCard( 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) { color = when (message.role) {
ChatRole.User -> HermesTheme.colors.userBubble ChatRole.User -> HermesTheme.colors.userBubble
ChatRole.Error -> HermesTheme.status.dangerPanel ChatRole.Error -> HermesTheme.status.dangerPanel
@@ -448,7 +483,7 @@ private fun ChatMessageCard(
} }
Spacer(Modifier.weight(1f)) Spacer(Modifier.weight(1f))
if (message.retryPrompt != null) { 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) Icon(Icons.Filled.Refresh, contentDescription = "Retry", tint = HermesTheme.colors.text)
} }
} }
@@ -467,18 +502,19 @@ private fun ChatMessageCard(
@Composable @Composable
private fun MarkdownText(text: String) { private fun MarkdownText(text: String) {
val display = LongTextPolicy.message(text.replace("**", ""))
SelectionContainer { SelectionContainer {
Text( Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
text = text.replace("**", ""), Text(text = display.text, color = HermesTheme.colors.text, style = HermesTheme.typography.body)
color = HermesTheme.colors.text, if (display.truncated) Text("Long response capped for safe display.", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
style = HermesTheme.typography.body }
)
} }
} }
@Composable @Composable
private fun CodeBlockCard(language: String, code: String, notify: (String) -> Unit) { private fun CodeBlockCard(language: String, code: String, notify: (String) -> Unit) {
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
val display = LongTextPolicy.code(code)
FlatCard(color = HermesTheme.colors.surfaceRaised) { FlatCard(color = HermesTheme.colors.surfaceRaised) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { 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)) clipboard.setText(AnnotatedString(code))
notify("Code copied.") notify("Code copied.")
}, },
modifier = Modifier.size(36.dp) modifier = Modifier.size(48.dp)
) { ) {
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy code", tint = HermesTheme.colors.text) Icon(Icons.Filled.ContentCopy, contentDescription = "Copy code", tint = HermesTheme.colors.text)
} }
} }
SelectionContainer { SelectionContainer {
Text( Text(
code, display.text,
modifier = Modifier modifier = Modifier
.horizontalScroll(rememberScrollState()) .horizontalScroll(rememberScrollState())
.padding(bottom = 2.dp), .padding(bottom = 2.dp),
@@ -504,6 +540,7 @@ private fun CodeBlockCard(language: String, code: String, notify: (String) -> Un
style = HermesTheme.typography.code 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( Row(
Modifier Modifier
.fillMaxWidth() .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, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
@@ -529,7 +571,7 @@ private fun ToolResultCard(result: ToolResultUi, onToggle: () -> Unit, notify: (
clipboard.setText(AnnotatedString(result.output)) clipboard.setText(AnnotatedString(result.output))
notify("Tool output copied.") 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) Icon(Icons.Filled.ContentCopy, contentDescription = "Copy tool output", tint = HermesTheme.colors.textMuted)
} }
@@ -581,7 +623,7 @@ private fun ChatComposer(
value = session.draft, value = session.draft,
onValueChange = onDraft, onValueChange = onDraft,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
placeholder = { Text("Message Hermes", color = HermesTheme.colors.textMuted) }, label = { Text("Message Hermes") },
minLines = 1, minLines = 1,
maxLines = 5, maxLines = 5,
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
@@ -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)
}
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width 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.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector 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.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation 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) { fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
ScreenList { ScreenList {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm), modifier = Modifier.fillMaxWidth()) { 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) Icon(Icons.Filled.ArrowBack, contentDescription = "Back", tint = HermesTheme.colors.text)
} }
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.panelTitle) Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.panelTitle)
@@ -80,7 +86,7 @@ fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnSco
@Composable @Composable
fun SectionHeader(title: String, detail: String) { 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)) { 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) 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 { FlatCard {
Row( Row(
Modifier 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), .padding(horizontal = 14.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md) 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) { fun ModeChip(text: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
Surface( Surface(
modifier = modifier modifier = modifier
.height(44.dp) .heightIn(min = 48.dp)
.clip(RoundedCornerShape(HermesTheme.shapes.chip)) .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, color = if (selected) HermesTheme.colors.primary else HermesTheme.colors.surfaceRaised,
contentColor = if (selected) HermesTheme.colors.onPrimary else HermesTheme.colors.text 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) Text(text, fontWeight = FontWeight.SemiBold, style = HermesTheme.typography.body)
} }
} }
@@ -249,15 +261,17 @@ fun NoticeCard(text: String, danger: Boolean = false) {
@Composable @Composable
fun CodeCard(text: String) { fun CodeCard(text: String) {
val display = LongTextPolicy.code(text)
FlatCard { FlatCard {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text( Text(
text, display.text,
Modifier Modifier.horizontalScroll(rememberScrollState()),
.horizontalScroll(rememberScrollState())
.padding(14.dp),
color = HermesTheme.colors.codeText, color = HermesTheme.colors.codeText,
style = HermesTheme.typography.code 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 { FlatCard {
Row( Row(
Modifier Modifier
.clickable(onClick = onClick) .clickable(role = Role.Button, onClick = onClick)
.semantics(mergeDescendants = true) { role = Role.Button }
.heightIn(min = 48.dp)
.padding(10.dp), .padding(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(10.dp)
@@ -343,7 +359,7 @@ fun PrimaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier
Button( Button(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
modifier = modifier.height(52.dp), modifier = modifier.heightIn(min = 52.dp),
shape = RoundedCornerShape(HermesTheme.shapes.input), shape = RoundedCornerShape(HermesTheme.shapes.input),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = HermesTheme.colors.primary, containerColor = HermesTheme.colors.primary,
@@ -364,7 +380,7 @@ fun SecondaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifi
Button( Button(
onClick = onClick, onClick = onClick,
enabled = enabled, enabled = enabled,
modifier = modifier.height(52.dp), modifier = modifier.heightIn(min = 52.dp),
shape = RoundedCornerShape(HermesTheme.shapes.input), shape = RoundedCornerShape(HermesTheme.shapes.input),
colors = ButtonDefaults.buttonColors( colors = ButtonDefaults.buttonColors(
containerColor = HermesTheme.colors.surface, containerColor = HermesTheme.colors.surface,
@@ -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)
}
}
}
+6
View File
@@ -1,5 +1,11 @@
# Hermes Mobile Blockers # 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 ## 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. 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.
+14
View File
@@ -26,6 +26,20 @@ Remaining for beta:
- Confirm adaptive accessibility: font scaling, TalkBack labels, contrast, keyboard/IME behavior, and reduced-motion tolerance. - 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. - 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 ## Beta Release Criteria
- Native Kotlin/Compose Android app is the shipped beta client. - Native Kotlin/Compose Android app is the shipped beta client.