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
@@ -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,50 +321,108 @@ fun HermesNativeApp() {
GatewayReevaluationHooks(api) { connectionState = it }
HermesMobileTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = HermesTheme.colors.background,
topBar = {
NativeTopBar(screen, connectionState.connected, onAction = {
if (screen == Screen.Inbox) {
resetInboxToken++
notify("New chat started.")
BoxWithConstraints(Modifier.fillMaxSize()) {
val fontScale = LocalDensity.current.fontScale
val layout = adaptiveLayout(maxWidth.value.toInt(), maxHeight.value.toInt(), fontScale)
val reduceMotion = rememberReducedMotion()
val selectScreen: (Screen) -> Unit = {
if (it != screen) vibrate(context)
screen = it
}
Row(Modifier.fillMaxSize()) {
if (layout.useSideNavigation) {
NativeSideBar(screen, selectScreen)
}
Scaffold(
modifier = Modifier.weight(1f),
containerColor = HermesTheme.colors.background,
topBar = {
NativeTopBar(screen, connectionState.connected, onAction = {
if (screen == Screen.Inbox) {
resetInboxToken++
notify("New chat started.")
}
})
},
bottomBar = {
if (!layout.useSideNavigation) NativeBottomBar(screen, selectScreen)
},
snackbarHost = { SnackbarHost(snackbar) }
) { padding ->
val contentModifier = Modifier
.fillMaxSize()
.padding(padding)
if (reduceMotion) {
NativeScreenContent(
screen = screen,
api = api,
connectionState = connectionState,
onConnectionState = { connectionState = it },
resetInboxToken = resetInboxToken,
onNewChat = {
resetInboxToken++
selectScreen(Screen.Inbox)
notify("New chat started.")
},
notify = ::notify,
modifier = contentModifier
)
} else {
AnimatedContent(
targetState = screen,
transitionSpec = { slideFor(initialState.ordinal, targetState.ordinal) },
modifier = contentModifier,
label = "screen-slide"
) { active ->
NativeScreenContent(
screen = active,
api = api,
connectionState = connectionState,
onConnectionState = { connectionState = it },
resetInboxToken = resetInboxToken,
onNewChat = {
resetInboxToken++
selectScreen(Screen.Inbox)
notify("New chat started.")
},
notify = ::notify
)
}
}
})
},
bottomBar = {
NativeBottomBar(
current = screen,
onSelect = {
if (it != screen) vibrate(context)
screen = it
}
)
},
snackbarHost = { SnackbarHost(snackbar) }
) { padding ->
AnimatedContent(
targetState = screen,
transitionSpec = { slideFor(initialState.ordinal, targetState.ordinal) },
modifier = Modifier
.fillMaxSize()
.padding(padding),
label = "screen-slide"
) { active ->
when (active) {
Screen.Inbox -> FakeInboxScreen(resetInboxToken, ::notify)
Screen.Terminals -> TerminalsScreen(api, ::notify, onNewChat = {
resetInboxToken++
screen = Screen.Inbox
notify("New chat started.")
})
Screen.Settings -> SettingsScreen(api, connectionState, { connectionState = it }, ::notify)
}
}
}
}
}
@Composable
private fun NativeScreenContent(
screen: Screen,
api: CompanionApi,
connectionState: ConnectionUiState,
onConnectionState: (ConnectionUiState) -> Unit,
resetInboxToken: Int,
onNewChat: () -> Unit,
notify: (String) -> Unit,
modifier: Modifier = Modifier
) {
Box(modifier) {
when (screen) {
Screen.Inbox -> FakeInboxScreen(resetInboxToken, notify)
Screen.Terminals -> TerminalsScreen(api, notify, onNewChat = onNewChat)
Screen.Settings -> SettingsScreen(api, connectionState, onConnectionState, notify)
}
}
}
@Composable
private fun rememberReducedMotion(): Boolean {
val context = LocalContext.current
return remember(context) {
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
}
}
@Composable
private fun GatewayReevaluationHooks(api: CompanionApi, onState: (ConnectionUiState) -> Unit) {
val context = LocalContext.current
@@ -418,15 +488,19 @@ fun NativeTopBar(screen: Screen, online: Boolean, onAction: () -> Unit) {
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(screen.title, color = HermesTheme.colors.text, style = HermesTheme.typography.screenTitle)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(screen.title, color = HermesTheme.colors.text, style = HermesTheme.typography.screenTitle, modifier = Modifier.semantics { heading() })
Row(
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
val statusColor = if (online) HermesTheme.status.success else HermesTheme.colors.textMuted
Box(Modifier.size(8.dp).clip(CircleShape).background(statusColor))
Text(if (online) "connected" else "offline", color = statusColor, style = HermesTheme.typography.caption, fontWeight = FontWeight.SemiBold)
}
}
if (screen == Screen.Inbox) {
IconButton(onClick = onAction, modifier = Modifier.size(44.dp)) {
IconButton(onClick = onAction, modifier = Modifier.size(48.dp)) {
Icon(Icons.Filled.Add, contentDescription = "New chat", tint = HermesTheme.colors.text)
}
}
@@ -440,7 +514,7 @@ fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) {
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.height(76.dp),
.heightIn(min = 72.dp),
containerColor = HermesTheme.colors.background,
tonalElevation = HermesTheme.elevation.none
) {
@@ -463,6 +537,35 @@ fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) {
}
}
@Composable
fun NativeSideBar(current: Screen, onSelect: (Screen) -> Unit) {
Surface(color = HermesTheme.colors.background, tonalElevation = HermesTheme.elevation.none) {
NavigationRail(
modifier = Modifier
.width(88.dp)
.statusBarsPadding()
.navigationBarsPadding(),
containerColor = HermesTheme.colors.background
) {
Screen.entries.forEach { item ->
NavigationRailItem(
selected = current == item,
onClick = { onSelect(item) },
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label, fontWeight = FontWeight.SemiBold) },
colors = androidx.compose.material3.NavigationRailItemDefaults.colors(
selectedIconColor = HermesTheme.colors.text,
selectedTextColor = HermesTheme.colors.text,
indicatorColor = HermesTheme.colors.surfaceRaised,
unselectedIconColor = HermesTheme.colors.textMuted,
unselectedTextColor = HermesTheme.colors.textMuted
)
)
}
}
}
}
@Composable
fun InboxScreen(api: CompanionApi, resetToken: Int) {
val scope = remember { CoroutineScope(Dispatchers.Main) }
@@ -562,7 +665,7 @@ fun ConversationThread(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text)
}
Column(Modifier.weight(1f)) {
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -56,6 +57,16 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -63,6 +74,7 @@ import androidx.compose.ui.unit.dp
import cloud.molberg.hermesmobile.design.EmptyState
import cloud.molberg.hermesmobile.design.FlatCard
import cloud.molberg.hermesmobile.design.HermesTheme
import cloud.molberg.hermesmobile.design.LongTextPolicy
import cloud.molberg.hermesmobile.design.ModeChip
import cloud.molberg.hermesmobile.design.NoticeCard
import cloud.molberg.hermesmobile.design.PrimaryButton
@@ -70,6 +82,7 @@ import cloud.molberg.hermesmobile.design.ScreenList
import cloud.molberg.hermesmobile.design.SecondaryButton
import cloud.molberg.hermesmobile.design.SectionHeader
import cloud.molberg.hermesmobile.design.TinyLabel
import cloud.molberg.hermesmobile.design.adaptiveLayout
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -154,7 +167,8 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val wide = maxWidth >= 720.dp
val layout = adaptiveLayout(maxWidth.value.toInt(), maxHeight.value.toInt(), LocalDensity.current.fontScale)
val wide = layout.useTwoPaneChat
val selected = state.selectedSession
if (!wide) {
BackHandler(selected != null) { state = state.copy(selectedSessionId = null) }
@@ -165,7 +179,7 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
ChatSessionRail(
state = state,
modifier = Modifier
.width(304.dp)
.width(layout.sessionPaneWidthDp.dp)
.fillMaxHeight(),
onNew = ::createSession,
onSelect = ::selectSession,
@@ -273,7 +287,13 @@ private fun SessionRow(session: ChatSessionUi, selected: Boolean, onClick: () ->
) {
Row(
Modifier
.clickable(onClick = onClick)
.clickable(role = Role.Button, onClick = onClick)
.semantics(mergeDescendants = true) {
role = Role.Button
this.selected = selected
stateDescription = if (session.busy) "Busy" else "Ready"
}
.heightIn(min = 48.dp)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
@@ -334,7 +354,7 @@ private fun ChatConversationPane(
ScreenList { EmptyState() }
return
}
Column(modifier) {
Column(modifier.semantics { paneTitle = "Conversation" }) {
ConversationHeader(session, state.connectionState, onBack)
LazyColumn(
modifier = Modifier.weight(1f),
@@ -384,7 +404,7 @@ private fun ConversationHeader(session: ChatSessionUi, connectionState: ChatConn
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (onBack != null) {
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text)
}
}
@@ -403,20 +423,33 @@ private fun ConversationHeader(session: ChatSessionUi, connectionState: ChatConn
@Composable
private fun ContextControlsCard(context: ChatContextControls, onContext: (ChatContextControls) -> Unit) {
FlatCard {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
BoxWithConstraints {
val stackControls = adaptiveLayout(maxWidth.value.toInt(), maxHeight.value.toInt(), LocalDensity.current.fontScale).stackDenseControls
FlatCard {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Tune, null, tint = HermesTheme.colors.textMuted, modifier = Modifier.size(18.dp))
Text("Context", color = HermesTheme.colors.text, style = HermesTheme.typography.rowTitle)
Spacer(Modifier.weight(1f))
Switch(checked = context.toolsEnabled, onCheckedChange = { onContext(context.copy(toolsEnabled = it)) })
Switch(
checked = context.toolsEnabled,
onCheckedChange = { onContext(context.copy(toolsEnabled = it)) },
modifier = Modifier.semantics { contentDescription = "Tools enabled" }
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
ModeChip("Default", context.model == "Hermes default", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes default")) }
ModeChip("Fast", context.model == "Hermes fast", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes fast")) }
ModeChip("Deep", context.effort == "High", Modifier.weight(1f)) { onContext(context.copy(effort = if (context.effort == "High") "Normal" else "High")) }
if (stackControls) {
ModeChip("Default", context.model == "Hermes default", Modifier.fillMaxWidth()) { onContext(context.copy(model = "Hermes default")) }
ModeChip("Fast", context.model == "Hermes fast", Modifier.fillMaxWidth()) { onContext(context.copy(model = "Hermes fast")) }
ModeChip("Deep", context.effort == "High", Modifier.fillMaxWidth()) { onContext(context.copy(effort = if (context.effort == "High") "Normal" else "High")) }
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
ModeChip("Default", context.model == "Hermes default", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes default")) }
ModeChip("Fast", context.model == "Hermes fast", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes fast")) }
ModeChip("Deep", context.effort == "High", Modifier.weight(1f)) { onContext(context.copy(effort = if (context.effort == "High") "Normal" else "High")) }
}
}
Text(context.workspace, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
}
@@ -431,7 +464,9 @@ private fun ChatMessageCard(
val isUser = message.role == ChatRole.User
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start) {
FlatCard(
modifier = Modifier.fillMaxWidth(if (isUser) 0.86f else 0.94f),
modifier = Modifier
.fillMaxWidth(if (isUser) 0.86f else 0.94f)
.semantics { if (message.streaming) liveRegion = LiveRegionMode.Polite },
color = when (message.role) {
ChatRole.User -> HermesTheme.colors.userBubble
ChatRole.Error -> HermesTheme.status.dangerPanel
@@ -448,7 +483,7 @@ private fun ChatMessageCard(
}
Spacer(Modifier.weight(1f))
if (message.retryPrompt != null) {
IconButton(onClick = onRetry, modifier = Modifier.size(38.dp)) {
IconButton(onClick = onRetry, modifier = Modifier.size(48.dp)) {
Icon(Icons.Filled.Refresh, contentDescription = "Retry", tint = HermesTheme.colors.text)
}
}
@@ -467,18 +502,19 @@ private fun ChatMessageCard(
@Composable
private fun MarkdownText(text: String) {
val display = LongTextPolicy.message(text.replace("**", ""))
SelectionContainer {
Text(
text = text.replace("**", ""),
color = HermesTheme.colors.text,
style = HermesTheme.typography.body
)
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(text = display.text, color = HermesTheme.colors.text, style = HermesTheme.typography.body)
if (display.truncated) Text("Long response capped for safe display.", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
}
}
}
@Composable
private fun CodeBlockCard(language: String, code: String, notify: (String) -> Unit) {
val clipboard = LocalClipboardManager.current
val display = LongTextPolicy.code(code)
FlatCard(color = HermesTheme.colors.surfaceRaised) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
@@ -489,14 +525,14 @@ private fun CodeBlockCard(language: String, code: String, notify: (String) -> Un
clipboard.setText(AnnotatedString(code))
notify("Code copied.")
},
modifier = Modifier.size(36.dp)
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy code", tint = HermesTheme.colors.text)
}
}
SelectionContainer {
Text(
code,
display.text,
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(bottom = 2.dp),
@@ -504,6 +540,7 @@ private fun CodeBlockCard(language: String, code: String, notify: (String) -> Un
style = HermesTheme.typography.code
)
}
if (display.truncated) Text("Large code capped for safe display. Copy includes the full code.", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
}
}
}
@@ -517,7 +554,12 @@ private fun ToolResultCard(result: ToolResultUi, onToggle: () -> Unit, notify: (
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onToggle),
.clickable(role = Role.Button, onClick = onToggle)
.semantics {
role = Role.Button
stateDescription = if (result.expanded) "Expanded" else "Collapsed"
}
.heightIn(min = 48.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -529,7 +571,7 @@ private fun ToolResultCard(result: ToolResultUi, onToggle: () -> Unit, notify: (
clipboard.setText(AnnotatedString(result.output))
notify("Tool output copied.")
},
modifier = Modifier.size(36.dp)
modifier = Modifier.size(48.dp)
) {
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy tool output", tint = HermesTheme.colors.textMuted)
}
@@ -581,7 +623,7 @@ private fun ChatComposer(
value = session.draft,
onValueChange = onDraft,
modifier = Modifier.weight(1f),
placeholder = { Text("Message Hermes", color = HermesTheme.colors.textMuted) },
label = { Text("Message Hermes") },
minLines = 1,
maxLines = 5,
shape = RoundedCornerShape(24.dp),
@@ -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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -46,6 +47,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
@@ -68,7 +74,7 @@ fun ScreenList(content: @Composable ColumnScope.() -> Unit) {
fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
ScreenList {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm), modifier = Modifier.fillMaxWidth()) {
androidx.compose.material3.IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
androidx.compose.material3.IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back", tint = HermesTheme.colors.text)
}
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.panelTitle)
@@ -80,7 +86,7 @@ fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnSco
@Composable
fun SectionHeader(title: String, detail: String) {
Column(Modifier.padding(top = HermesTheme.spacing.sm, start = HermesTheme.spacing.xxs, end = HermesTheme.spacing.xxs), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.sectionTitle)
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.sectionTitle, modifier = Modifier.semantics { heading() })
Text(detail, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
}
}
@@ -106,7 +112,9 @@ fun ListRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> U
FlatCard {
Row(
Modifier
.clickable(onClick = onClick)
.clickable(role = Role.Button, onClick = onClick)
.semantics(mergeDescendants = true) { role = Role.Button }
.heightIn(min = 48.dp)
.padding(horizontal = 14.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)
@@ -133,13 +141,17 @@ fun ListRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> U
fun ModeChip(text: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
Surface(
modifier = modifier
.height(44.dp)
.heightIn(min = 48.dp)
.clip(RoundedCornerShape(HermesTheme.shapes.chip))
.clickable(onClick = onClick),
.clickable(role = Role.Button, onClick = onClick)
.semantics {
role = Role.Button
this.selected = selected
},
color = if (selected) HermesTheme.colors.primary else HermesTheme.colors.surfaceRaised,
contentColor = if (selected) HermesTheme.colors.onPrimary else HermesTheme.colors.text
) {
Box(contentAlignment = Alignment.Center) {
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) {
Text(text, fontWeight = FontWeight.SemiBold, style = HermesTheme.typography.body)
}
}
@@ -249,15 +261,17 @@ fun NoticeCard(text: String, danger: Boolean = false) {
@Composable
fun CodeCard(text: String) {
val display = LongTextPolicy.code(text)
FlatCard {
Text(
text,
Modifier
.horizontalScroll(rememberScrollState())
.padding(14.dp),
color = HermesTheme.colors.codeText,
style = HermesTheme.typography.code
)
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
display.text,
Modifier.horizontalScroll(rememberScrollState()),
color = HermesTheme.colors.codeText,
style = HermesTheme.typography.code
)
if (display.truncated) Text("Large code capped for safe display.", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
}
}
}
@@ -266,7 +280,9 @@ fun FileRow(name: String, type: String, size: Long, onClick: () -> Unit) {
FlatCard {
Row(
Modifier
.clickable(onClick = onClick)
.clickable(role = Role.Button, onClick = onClick)
.semantics(mergeDescendants = true) { role = Role.Button }
.heightIn(min = 48.dp)
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
@@ -343,7 +359,7 @@ fun PrimaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.height(52.dp),
modifier = modifier.heightIn(min = 52.dp),
shape = RoundedCornerShape(HermesTheme.shapes.input),
colors = ButtonDefaults.buttonColors(
containerColor = HermesTheme.colors.primary,
@@ -364,7 +380,7 @@ fun SecondaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifi
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.height(52.dp),
modifier = modifier.heightIn(min = 52.dp),
shape = RoundedCornerShape(HermesTheme.shapes.input),
colors = ButtonDefaults.buttonColors(
containerColor = HermesTheme.colors.surface,
@@ -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)
}
}
}