fix(#586): route hardcoded colors through theme tokens (Material You / presets) (#592)

Co-authored-by: M57 <m57@hermes-control.app>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-14 00:57:12 +03:00
1 parent 1870e4bb01
commit 29b630a2ec
9 files changed
+207 -48

No files matched your search

@@ -68,6 +68,8 @@ val StatusYellowContainer = Color(0xFF3D2F0F)
val StatusBlue = Color(0xFF4DA8FF)
val StatusBlueContainer = Color(0xFF0F2A3D)
val StatusGrey = Color(0xFF9E9E9E)
val StatusGreyDark = Color(0xFF1A1A24)
val StatusGreyLight = Color(0xFFF5F5F5)
// ── Chat-specific colours (always brand-defined for consistency) ───────
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.theme
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
/**
* Semantic status colour set — always brand-defined (never dynamic).
@@ -16,25 +17,58 @@ import androidx.compose.ui.graphics.Color
data class HermesStatusColors(
val success: Color,
val successContainer: Color,
val onSuccess: Color,
val warning: Color,
val warningContainer: Color,
val onWarning: Color,
val error: Color,
val errorContainer: Color,
val onError: Color,
val info: Color,
val infoContainer: Color,
val onInfo: Color,
val neutral: Color = StatusGrey,
)
/**
* Theme-aware colors for markdown/search highlighting. Built per-composition from
* [LocalHermesStatusColors] so `==mark==` and search terms follow the active preset
* instead of a fixed yellow.
*/
data class SearchHighlightColors(
val markupBackground: Color,
val searchBackground: Color,
val searchForeground: Color,
val currentSearchBackground: Color,
val currentSearchForeground: Color,
)
/** Build the highlight palette for the current theme preset. */
internal fun onColorFor(color: Color): Color = if (color.luminance() > 0.5f) StatusGreyDark else StatusGreyLight
internal fun searchHighlightColors(statusColors: HermesStatusColors): SearchHighlightColors =
SearchHighlightColors(
markupBackground = statusColors.warning.copy(alpha = 0.3f),
searchBackground = statusColors.warningContainer,
searchForeground = onColorFor(statusColors.warningContainer),
currentSearchBackground = statusColors.warning,
currentSearchForeground = statusColors.onWarning,
)
val LocalHermesStatusColors =
staticCompositionLocalOf {
HermesStatusColors(
success = StatusGreen,
successContainer = StatusGreenContainer,
onSuccess = StatusGreyLight,
warning = StatusYellow,
warningContainer = StatusYellowContainer,
onWarning = StatusGreyLight,
error = StatusRed,
errorContainer = StatusRedContainer,
onError = StatusGreyLight,
info = StatusBlue,
infoContainer = StatusBlueContainer,
onInfo = StatusGreyLight,
)
}
@@ -339,12 +339,16 @@ fun HermesControlTheme(
HermesStatusColors(
success = Color(0xFFB8BB26),
successContainer = Color(0xFF32302F),
onSuccess = Color(0xFF282828),
warning = Color(0xFFFABD2F),
warningContainer = Color(0xFF3C3836),
onWarning = Color(0xFF282828),
error = Color(0xFFFB4934),
errorContainer = Color(0xFF282828),
onError = Color(0xFF282828),
info = Color(0xFF83A598),
infoContainer = Color(0xFF3C3836),
onInfo = Color(0xFF282828),
)
}
@@ -352,12 +356,16 @@ fun HermesControlTheme(
HermesStatusColors(
success = Color(0xFF98971A),
successContainer = Color(0xFFEBDBB2),
onSuccess = Color(0xFFEBDBB2),
warning = Color(0xFFD79921),
warningContainer = Color(0xFFEBDBB2),
onWarning = Color(0xFFEBDBB2),
error = Color(0xFFCC241D),
errorContainer = Color(0xFFEBDBB2),
onError = Color(0xFFEBDBB2),
info = Color(0xFF458588),
infoContainer = Color(0xFFEBDBB2),
onInfo = Color(0xFFEBDBB2),
)
}
@@ -365,12 +373,16 @@ fun HermesControlTheme(
HermesStatusColors(
success = Color(0xFFA6E3A1),
successContainer = Color(0xFF313244),
onSuccess = Color(0xFF11111B),
warning = Color(0xFFF9E2AF),
warningContainer = Color(0xFF313244),
onWarning = Color(0xFF11111B),
error = Color(0xFFF38BA8),
errorContainer = Color(0xFF313244),
onError = Color(0xFF11111B),
info = Color(0xFF89B4FA),
infoContainer = Color(0xFF313244),
onInfo = Color(0xFF11111B),
)
}
@@ -378,12 +390,16 @@ fun HermesControlTheme(
HermesStatusColors(
success = Color(0xFF40A02B),
successContainer = Color(0xFFE6E9EF),
onSuccess = Color(0xFFE6E9EF),
warning = Color(0xFFDF8E1D),
warningContainer = Color(0xFFE6E9EF),
onWarning = Color(0xFFE6E9EF),
error = Color(0xFFD20F39),
errorContainer = Color(0xFFE6E9EF),
onError = Color(0xFFE6E9EF),
info = Color(0xFF1E66F5),
infoContainer = Color(0xFFE6E9EF),
onInfo = Color(0xFFE6E9EF),
)
}
@@ -391,12 +407,16 @@ fun HermesControlTheme(
HermesStatusColors(
success = Color(0xFF6FEA8B),
successContainer = Color(0xFF0A3A14),
onSuccess = Color(0xFF0F0B1A),
warning = Color(0xFFFFB347),
warningContainer = Color(0xFF3A2000),
onWarning = Color(0xFF0F0B1A),
error = Color(0xFFFF3D68),
errorContainer = Color(0xFF3A0A14),
onError = Color(0xFF0F0B1A),
info = Color(0xFF60F0FF),
infoContainer = Color(0xFF003B40),
onInfo = Color(0xFF0F0B1A),
)
}
@@ -404,12 +424,16 @@ fun HermesControlTheme(
HermesStatusColors(
success = Color(0xFF1B873A),
successContainer = Color(0xFFD7F5E0),
onSuccess = Color(0xFFFDF8FF),
warning = Color(0xFFB87800),
warningContainer = Color(0xFFFFEAB3),
onWarning = Color(0xFFFDF8FF),
error = Color(0xFFB3261E),
errorContainer = Color(0xFFF9DEDC),
onError = Color(0xFFFDF8FF),
info = Color(0xFF2E6FBD),
infoContainer = Color(0xFFD4E7FF),
onInfo = Color(0xFFFDF8FF),
)
}
@@ -417,12 +441,16 @@ fun HermesControlTheme(
HermesStatusColors(
success = StatusGreen,
successContainer = StatusGreenContainer,
onSuccess = StatusGreyLight,
warning = StatusYellow,
warningContainer = StatusYellowContainer,
onWarning = StatusGreyLight,
error = StatusRed,
errorContainer = StatusRedContainer,
onError = StatusGreyLight,
info = StatusBlue,
infoContainer = StatusBlueContainer,
onInfo = StatusGreyLight,
)
}
@@ -430,12 +458,16 @@ fun HermesControlTheme(
HermesStatusColors(
success = Color(0xFF1B873A),
successContainer = Color(0xFFD7F5E0),
onSuccess = Color(0xFFFDF8FF),
warning = HermesAmberDark,
warningContainer = Color(0xFFFFEAB3),
onWarning = Color(0xFFFDF8FF),
error = Color(0xFFB3261E),
errorContainer = Color(0xFFF9DEDC),
onError = Color(0xFFFDF8FF),
info = Color(0xFF2E6FBD),
infoContainer = Color(0xFFD4E7FF),
onInfo = Color(0xFFFDF8FF),
)
}
}
@@ -84,13 +84,15 @@ import com.m57.hermescontrol.data.model.Attachment
import com.m57.hermescontrol.data.remote.OkHttpProvider
import com.m57.hermescontrol.theme.AssistantBubble
import com.m57.hermescontrol.theme.AssistantBubbleLight
import com.m57.hermescontrol.theme.DarkOnSurface
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LightOnSurface
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.theme.SystemMessageColor
import com.m57.hermescontrol.theme.ToolChipColor
import com.m57.hermescontrol.theme.ToolChipColorLight
import com.m57.hermescontrol.theme.UserBubble
import com.m57.hermescontrol.theme.onColorFor
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonArray
@@ -176,10 +178,17 @@ private fun UserBubble(
}
}
val statusColors = LocalHermesStatusColors.current
val highlightedText =
remember(message.content, searchQuery, isCurrentMatch) {
remember(message.content, searchQuery, isCurrentMatch, statusColors) {
if (searchQuery.isNotBlank()) {
buildHighlightedString(message.content, searchQuery, isCurrentMatch)
buildHighlightedString(
message.content,
searchQuery,
isCurrentMatch,
statusColors,
)
} else {
AnnotatedString(message.content)
}
@@ -207,13 +216,13 @@ private fun UserBubble(
if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) {
MaterialTheme.colorScheme.onPrimary
} else {
Color(0xFF1A1A24)
LightOnSurface
}
} else {
if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) {
MaterialTheme.colorScheme.onPrimary
} else {
Color.White
DarkOnSurface
}
}
Box {
@@ -324,13 +333,13 @@ private fun AssistantBubble(
if (MaterialTheme.colorScheme.onSurface.luminance() < 0.5f) {
MaterialTheme.colorScheme.onSurface
} else {
Color(0xFF1A1A24)
LightOnSurface
}
} else {
if (MaterialTheme.colorScheme.onSurface.luminance() > 0.5f) {
MaterialTheme.colorScheme.onSurface
} else {
Color(0xFFE8E6EE)
DarkOnSurface
}
}
val clipboard = LocalClipboard.current
@@ -1719,6 +1728,7 @@ fun parseToolOutput(
private fun ExpandedToolContent(
parsed: ParsedToolData,
contentColor: Color,
statusColors: HermesStatusColors,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Summary line at top of expanded view
@@ -1753,7 +1763,7 @@ private fun ExpandedToolContent(
text = stringResource(R.string.chat_tool_stderr, it),
style =
MaterialTheme.typography.bodySmall.copy(
color = StatusRed.copy(alpha = 0.9f),
color = statusColors.error.copy(alpha = 0.9f),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
),
@@ -1764,7 +1774,7 @@ private fun ExpandedToolContent(
text = stringResource(R.string.chat_tool_execution_error, it),
style =
MaterialTheme.typography.bodySmall.copy(
color = StatusRed,
color = statusColors.error,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
@@ -1777,7 +1787,7 @@ private fun ExpandedToolContent(
text = stringResource(R.string.chat_tool_exit_code, code),
style =
MaterialTheme.typography.labelSmall.copy(
color = StatusRed,
color = statusColors.error,
fontWeight = FontWeight.Medium,
),
)
@@ -1854,6 +1864,7 @@ private fun ToolBubble(
var showRawJson by remember { mutableStateOf(false) }
val chipColor = if (isDarkTheme) ToolChipColor else ToolChipColorLight
val contentColor = if (isDarkTheme) Color.White else Color.Black
val statusColors = LocalHermesStatusColors.current
val parsed =
remember(message.content, message.toolName, message.toolStatus) {
@@ -1892,7 +1903,7 @@ private fun ToolBubble(
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
// ── Header row: icon + tool name ──
HeaderRow(message, config, contentColor)
HeaderRow(message, config, contentColor, statusColors)
// ── Tool progress preview (tool.progress) ──
if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) {
@@ -1978,7 +1989,7 @@ private fun ToolBubble(
// Clean structured expanded view
Box {
SelectionContainer {
ExpandedToolContent(parsed, contentColor)
ExpandedToolContent(parsed, contentColor, statusColors)
}
CopyButton(
visible = showCopyButton,
@@ -2048,6 +2059,7 @@ private fun HeaderRow(
message: ChatMessage,
config: ToolDisplayConfig,
contentColor: Color,
statusColors: HermesStatusColors,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -2069,8 +2081,8 @@ private fun HeaderRow(
}
val tint =
when (message.toolStatus) {
ToolStatus.COMPLETED -> StatusGreen
ToolStatus.FAILED -> StatusRed
ToolStatus.COMPLETED -> statusColors.success
ToolStatus.FAILED -> statusColors.error
else -> contentColor.copy(alpha = 0.6f)
}
Icon(
@@ -2200,8 +2212,14 @@ private fun buildHighlightedString(
text: String,
query: String,
isCurrentMatch: Boolean = false,
statusColors: HermesStatusColors,
): AnnotatedString {
val highlightColor = if (isCurrentMatch) Color(0xFFF57C00) else Color(0xFFFFF176).copy(alpha = 0.9f)
val (highlightColor, highlightText) =
if (isCurrentMatch) {
statusColors.warning to statusColors.onWarning
} else {
statusColors.warningContainer to onColorFor(statusColors.warningContainer)
}
return buildAnnotatedString {
var i = 0
while (i < text.length) {
@@ -2216,7 +2234,7 @@ private fun buildHighlightedString(
append(text.substring(i, matchEnd))
}
// Append the match highlighted
withStyle(SpanStyle(background = highlightColor, color = Color(0xFF1A1A24))) {
withStyle(SpanStyle(background = highlightColor, color = highlightText)) {
append(text.substring(matchEnd, matchEnd + query.length))
}
i = matchEnd + query.length
@@ -131,7 +131,6 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.ui.common.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -358,7 +357,7 @@ fun ChatScreen(
Modifier
.size(10.dp)
.clip(CircleShape)
.background(StatusRed),
.background(LocalHermesStatusColors.current.error),
)
}
}
@@ -57,11 +57,13 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.SearchHighlightColors
import com.m57.hermescontrol.theme.searchHighlightColors
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"‘’]+""")
private val HIGHLIGHT_BG = Color(0xFFFFFF00).copy(alpha = 0.3f)
private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""")
private val TABLE_COL_WIDTH = 160.dp
private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""")
@@ -84,6 +86,8 @@ fun MarkdownText(
isCurrentMatch: Boolean = false,
modifier: Modifier = Modifier,
) {
val statusColors = LocalHermesStatusColors.current
val highlights = searchHighlightColors(statusColors)
if (isStreaming) {
Text(
text = text,
@@ -121,7 +125,7 @@ fun MarkdownText(
Text(
text =
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor)
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor, highlights)
},
color = textColor,
style =
@@ -145,7 +149,14 @@ fun MarkdownText(
Text(
text =
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor)
parseInline(
block.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
},
color = textColor,
style = MaterialTheme.typography.bodyMedium,
@@ -180,7 +191,14 @@ fun MarkdownText(
Text(
text =
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor)
parseInline(
block.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
},
color = textColor,
style = MaterialTheme.typography.bodyMedium,
@@ -203,7 +221,14 @@ fun MarkdownText(
Text(
text =
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor)
parseInline(
block.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
},
color = textColor,
style = MaterialTheme.typography.bodyMedium,
@@ -229,7 +254,14 @@ fun MarkdownText(
Text(
text =
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor)
parseInline(
block.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
},
color = textColor,
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
@@ -283,7 +315,14 @@ fun MarkdownText(
Text(
text =
remember(note.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(note.text, textColor, searchQuery, isCurrentMatch, linkColor)
parseInline(
note.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
},
color = textColor,
style = MaterialTheme.typography.bodySmall,
@@ -298,7 +337,7 @@ fun MarkdownText(
Text(
text =
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor)
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor, highlights)
},
color = textColor,
style = MaterialTheme.typography.bodyMedium,
@@ -663,9 +702,14 @@ internal fun parseInline(
searchQuery: String,
isCurrentMatch: Boolean,
linkColor: Color,
highlights: SearchHighlightColors,
): AnnotatedString {
val searchHighlightColor =
if (isCurrentMatch) Color(0xFFF57C00) else Color(0xFFFFF176).copy(alpha = 0.9f)
if (isCurrentMatch) {
highlights.currentSearchBackground to highlights.currentSearchForeground
} else {
highlights.searchBackground to highlights.searchForeground
}
return buildAnnotatedString {
var i = 0
@@ -732,7 +776,7 @@ internal fun parseInline(
src.startsWith("==", i) -> {
val end = src.indexOf("==", i + 2)
if (end != -1) {
withStyle(SpanStyle(background = HIGHLIGHT_BG)) {
withStyle(SpanStyle(background = highlights.markupBackground)) {
append(src.substring(i + 2, end))
}
i = end + 2
@@ -881,8 +925,8 @@ internal fun parseInline(
src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {
withStyle(
SpanStyle(
background = searchHighlightColor,
color = Color(0xFF1A1A24),
background = searchHighlightColor.first,
color = searchHighlightColor.second,
),
) {
append(src.substring(i, i + searchQuery.length))
@@ -29,13 +29,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState
@@ -50,6 +50,7 @@ fun GatewayScreen(
viewModel: GatewayViewModel = viewModel { GatewayViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val statusColors = LocalHermesStatusColors.current
LaunchedEffect(Unit) {
viewModel.loadStatus()
@@ -188,8 +189,8 @@ fun GatewayScreen(
enabled = !isRunning && !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = Color(0xFF4CAF50), // Green for start
contentColor = Color.White,
containerColor = statusColors.success,
contentColor = statusColors.onSuccess,
),
) {
Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null)
@@ -203,8 +204,8 @@ fun GatewayScreen(
enabled = isRunning && !state.isActionRunning,
colors =
ButtonDefaults.buttonColors(
containerColor = Color(0xFFF44336), // Red for stop
contentColor = Color.White,
containerColor = statusColors.error,
contentColor = statusColors.onError,
),
) {
Text(stringResource(R.string.gateway_action_stop))
@@ -270,9 +271,9 @@ fun GatewayScreen(
val stateText = pStatus?.state?.uppercase() ?: "UNKNOWN"
val badgeColor =
when (stateText) {
"RUNNING" -> Color(0xFF4CAF50)
"STOPPED" -> Color(0xFFF44336)
"ERROR" -> Color(0xFFFF9800)
"RUNNING" -> statusColors.success
"STOPPED" -> statusColors.error
"ERROR" -> statusColors.warning
else -> MaterialTheme.colorScheme.onSurfaceVariant
}
Text(
@@ -575,7 +575,7 @@ fun SessionsScreen(
CircularProgressIndicator(
modifier = Modifier.size(18.dp),
strokeWidth = 2.dp,
color = Color.White,
color = statusColors.onError,
)
} else {
Icon(