feat: improve adaptive accessibility safeguards
This commit is contained in:
@@ -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`).
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:screenOrientation="portrait">
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
||||
@@ -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,8 +321,20 @@ fun HermesNativeApp() {
|
||||
GatewayReevaluationHooks(api) { connectionState = it }
|
||||
|
||||
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(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
modifier = Modifier.weight(1f),
|
||||
containerColor = HermesTheme.colors.background,
|
||||
topBar = {
|
||||
NativeTopBar(screen, connectionState.connected, onAction = {
|
||||
@@ -321,37 +345,83 @@ fun HermesNativeApp() {
|
||||
})
|
||||
},
|
||||
bottomBar = {
|
||||
NativeBottomBar(
|
||||
current = screen,
|
||||
onSelect = {
|
||||
if (it != screen) vibrate(context)
|
||||
screen = it
|
||||
}
|
||||
)
|
||||
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 = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding),
|
||||
modifier = contentModifier,
|
||||
label = "screen-slide"
|
||||
) { active ->
|
||||
when (active) {
|
||||
Screen.Inbox -> FakeInboxScreen(resetInboxToken, ::notify)
|
||||
Screen.Terminals -> TerminalsScreen(api, ::notify, onNewChat = {
|
||||
NativeScreenContent(
|
||||
screen = active,
|
||||
api = api,
|
||||
connectionState = connectionState,
|
||||
onConnectionState = { connectionState = it },
|
||||
resetInboxToken = resetInboxToken,
|
||||
onNewChat = {
|
||||
resetInboxToken++
|
||||
screen = Screen.Inbox
|
||||
selectScreen(Screen.Inbox)
|
||||
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
|
||||
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)
|
||||
) {
|
||||
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)) {
|
||||
|
||||
+60
-18
@@ -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,23 +423,36 @@ private fun ConversationHeader(session: ChatSessionUi, connectionState: ChatConn
|
||||
|
||||
@Composable
|
||||
private fun ContextControlsCard(context: ChatContextControls, onContext: (ChatContextControls) -> Unit) {
|
||||
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" }
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatMessageCard(
|
||||
@@ -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),
|
||||
|
||||
+77
@@ -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)
|
||||
}
|
||||
+29
-13
@@ -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 {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text,
|
||||
Modifier
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(14.dp),
|
||||
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,
|
||||
|
||||
+62
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user