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 StatusBlue = Color(0xFF4DA8FF)
val StatusBlueContainer = Color(0xFF0F2A3D) val StatusBlueContainer = Color(0xFF0F2A3D)
val StatusGrey = Color(0xFF9E9E9E) val StatusGrey = Color(0xFF9E9E9E)
val StatusGreyDark = Color(0xFF1A1A24)
val StatusGreyLight = Color(0xFFF5F5F5)
// ── Chat-specific colours (always brand-defined for consistency) ─────── // ── 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.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
/** /**
* Semantic status colour set — always brand-defined (never dynamic). * Semantic status colour set — always brand-defined (never dynamic).
@@ -16,25 +17,58 @@ import androidx.compose.ui.graphics.Color
data class HermesStatusColors( data class HermesStatusColors(
val success: Color, val success: Color,
val successContainer: Color, val successContainer: Color,
val onSuccess: Color,
val warning: Color, val warning: Color,
val warningContainer: Color, val warningContainer: Color,
val onWarning: Color,
val error: Color, val error: Color,
val errorContainer: Color, val errorContainer: Color,
val onError: Color,
val info: Color, val info: Color,
val infoContainer: Color, val infoContainer: Color,
val onInfo: Color,
val neutral: Color = StatusGrey, 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 = val LocalHermesStatusColors =
staticCompositionLocalOf { staticCompositionLocalOf {
HermesStatusColors( HermesStatusColors(
success = StatusGreen, success = StatusGreen,
successContainer = StatusGreenContainer, successContainer = StatusGreenContainer,
onSuccess = StatusGreyLight,
warning = StatusYellow, warning = StatusYellow,
warningContainer = StatusYellowContainer, warningContainer = StatusYellowContainer,
onWarning = StatusGreyLight,
error = StatusRed, error = StatusRed,
errorContainer = StatusRedContainer, errorContainer = StatusRedContainer,
onError = StatusGreyLight,
info = StatusBlue, info = StatusBlue,
infoContainer = StatusBlueContainer, infoContainer = StatusBlueContainer,
onInfo = StatusGreyLight,
) )
} }
@@ -339,12 +339,16 @@ fun HermesControlTheme(
HermesStatusColors( HermesStatusColors(
success = Color(0xFFB8BB26), success = Color(0xFFB8BB26),
successContainer = Color(0xFF32302F), successContainer = Color(0xFF32302F),
onSuccess = Color(0xFF282828),
warning = Color(0xFFFABD2F), warning = Color(0xFFFABD2F),
warningContainer = Color(0xFF3C3836), warningContainer = Color(0xFF3C3836),
onWarning = Color(0xFF282828),
error = Color(0xFFFB4934), error = Color(0xFFFB4934),
errorContainer = Color(0xFF282828), errorContainer = Color(0xFF282828),
onError = Color(0xFF282828),
info = Color(0xFF83A598), info = Color(0xFF83A598),
infoContainer = Color(0xFF3C3836), infoContainer = Color(0xFF3C3836),
onInfo = Color(0xFF282828),
) )
} }
@@ -352,12 +356,16 @@ fun HermesControlTheme(
HermesStatusColors( HermesStatusColors(
success = Color(0xFF98971A), success = Color(0xFF98971A),
successContainer = Color(0xFFEBDBB2), successContainer = Color(0xFFEBDBB2),
onSuccess = Color(0xFFEBDBB2),
warning = Color(0xFFD79921), warning = Color(0xFFD79921),
warningContainer = Color(0xFFEBDBB2), warningContainer = Color(0xFFEBDBB2),
onWarning = Color(0xFFEBDBB2),
error = Color(0xFFCC241D), error = Color(0xFFCC241D),
errorContainer = Color(0xFFEBDBB2), errorContainer = Color(0xFFEBDBB2),
onError = Color(0xFFEBDBB2),
info = Color(0xFF458588), info = Color(0xFF458588),
infoContainer = Color(0xFFEBDBB2), infoContainer = Color(0xFFEBDBB2),
onInfo = Color(0xFFEBDBB2),
) )
} }
@@ -365,12 +373,16 @@ fun HermesControlTheme(
HermesStatusColors( HermesStatusColors(
success = Color(0xFFA6E3A1), success = Color(0xFFA6E3A1),
successContainer = Color(0xFF313244), successContainer = Color(0xFF313244),
onSuccess = Color(0xFF11111B),
warning = Color(0xFFF9E2AF), warning = Color(0xFFF9E2AF),
warningContainer = Color(0xFF313244), warningContainer = Color(0xFF313244),
onWarning = Color(0xFF11111B),
error = Color(0xFFF38BA8), error = Color(0xFFF38BA8),
errorContainer = Color(0xFF313244), errorContainer = Color(0xFF313244),
onError = Color(0xFF11111B),
info = Color(0xFF89B4FA), info = Color(0xFF89B4FA),
infoContainer = Color(0xFF313244), infoContainer = Color(0xFF313244),
onInfo = Color(0xFF11111B),
) )
} }
@@ -378,12 +390,16 @@ fun HermesControlTheme(
HermesStatusColors( HermesStatusColors(
success = Color(0xFF40A02B), success = Color(0xFF40A02B),
successContainer = Color(0xFFE6E9EF), successContainer = Color(0xFFE6E9EF),
onSuccess = Color(0xFFE6E9EF),
warning = Color(0xFFDF8E1D), warning = Color(0xFFDF8E1D),
warningContainer = Color(0xFFE6E9EF), warningContainer = Color(0xFFE6E9EF),
onWarning = Color(0xFFE6E9EF),
error = Color(0xFFD20F39), error = Color(0xFFD20F39),
errorContainer = Color(0xFFE6E9EF), errorContainer = Color(0xFFE6E9EF),
onError = Color(0xFFE6E9EF),
info = Color(0xFF1E66F5), info = Color(0xFF1E66F5),
infoContainer = Color(0xFFE6E9EF), infoContainer = Color(0xFFE6E9EF),
onInfo = Color(0xFFE6E9EF),
) )
} }
@@ -391,12 +407,16 @@ fun HermesControlTheme(
HermesStatusColors( HermesStatusColors(
success = Color(0xFF6FEA8B), success = Color(0xFF6FEA8B),
successContainer = Color(0xFF0A3A14), successContainer = Color(0xFF0A3A14),
onSuccess = Color(0xFF0F0B1A),
warning = Color(0xFFFFB347), warning = Color(0xFFFFB347),
warningContainer = Color(0xFF3A2000), warningContainer = Color(0xFF3A2000),
onWarning = Color(0xFF0F0B1A),
error = Color(0xFFFF3D68), error = Color(0xFFFF3D68),
errorContainer = Color(0xFF3A0A14), errorContainer = Color(0xFF3A0A14),
onError = Color(0xFF0F0B1A),
info = Color(0xFF60F0FF), info = Color(0xFF60F0FF),
infoContainer = Color(0xFF003B40), infoContainer = Color(0xFF003B40),
onInfo = Color(0xFF0F0B1A),
) )
} }
@@ -404,12 +424,16 @@ fun HermesControlTheme(
HermesStatusColors( HermesStatusColors(
success = Color(0xFF1B873A), success = Color(0xFF1B873A),
successContainer = Color(0xFFD7F5E0), successContainer = Color(0xFFD7F5E0),
onSuccess = Color(0xFFFDF8FF),
warning = Color(0xFFB87800), warning = Color(0xFFB87800),
warningContainer = Color(0xFFFFEAB3), warningContainer = Color(0xFFFFEAB3),
onWarning = Color(0xFFFDF8FF),
error = Color(0xFFB3261E), error = Color(0xFFB3261E),
errorContainer = Color(0xFFF9DEDC), errorContainer = Color(0xFFF9DEDC),
onError = Color(0xFFFDF8FF),
info = Color(0xFF2E6FBD), info = Color(0xFF2E6FBD),
infoContainer = Color(0xFFD4E7FF), infoContainer = Color(0xFFD4E7FF),
onInfo = Color(0xFFFDF8FF),
) )
} }
@@ -417,12 +441,16 @@ fun HermesControlTheme(
HermesStatusColors( HermesStatusColors(
success = StatusGreen, success = StatusGreen,
successContainer = StatusGreenContainer, successContainer = StatusGreenContainer,
onSuccess = StatusGreyLight,
warning = StatusYellow, warning = StatusYellow,
warningContainer = StatusYellowContainer, warningContainer = StatusYellowContainer,
onWarning = StatusGreyLight,
error = StatusRed, error = StatusRed,
errorContainer = StatusRedContainer, errorContainer = StatusRedContainer,
onError = StatusGreyLight,
info = StatusBlue, info = StatusBlue,
infoContainer = StatusBlueContainer, infoContainer = StatusBlueContainer,
onInfo = StatusGreyLight,
) )
} }
@@ -430,12 +458,16 @@ fun HermesControlTheme(
HermesStatusColors( HermesStatusColors(
success = Color(0xFF1B873A), success = Color(0xFF1B873A),
successContainer = Color(0xFFD7F5E0), successContainer = Color(0xFFD7F5E0),
onSuccess = Color(0xFFFDF8FF),
warning = HermesAmberDark, warning = HermesAmberDark,
warningContainer = Color(0xFFFFEAB3), warningContainer = Color(0xFFFFEAB3),
onWarning = Color(0xFFFDF8FF),
error = Color(0xFFB3261E), error = Color(0xFFB3261E),
errorContainer = Color(0xFFF9DEDC), errorContainer = Color(0xFFF9DEDC),
onError = Color(0xFFFDF8FF),
info = Color(0xFF2E6FBD), info = Color(0xFF2E6FBD),
infoContainer = Color(0xFFD4E7FF), 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.data.remote.OkHttpProvider
import com.m57.hermescontrol.theme.AssistantBubble import com.m57.hermescontrol.theme.AssistantBubble
import com.m57.hermescontrol.theme.AssistantBubbleLight 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.LocalHermesStatusColors
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.theme.SystemMessageColor import com.m57.hermescontrol.theme.SystemMessageColor
import com.m57.hermescontrol.theme.ToolChipColor import com.m57.hermescontrol.theme.ToolChipColor
import com.m57.hermescontrol.theme.ToolChipColorLight import com.m57.hermescontrol.theme.ToolChipColorLight
import com.m57.hermescontrol.theme.UserBubble import com.m57.hermescontrol.theme.UserBubble
import com.m57.hermescontrol.theme.onColorFor
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonArray
@@ -176,10 +178,17 @@ private fun UserBubble(
} }
} }
val statusColors = LocalHermesStatusColors.current
val highlightedText = val highlightedText =
remember(message.content, searchQuery, isCurrentMatch) { remember(message.content, searchQuery, isCurrentMatch, statusColors) {
if (searchQuery.isNotBlank()) { if (searchQuery.isNotBlank()) {
buildHighlightedString(message.content, searchQuery, isCurrentMatch) buildHighlightedString(
message.content,
searchQuery,
isCurrentMatch,
statusColors,
)
} else { } else {
AnnotatedString(message.content) AnnotatedString(message.content)
} }
@@ -207,13 +216,13 @@ private fun UserBubble(
if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) { if (MaterialTheme.colorScheme.onPrimary.luminance() < 0.5f) {
MaterialTheme.colorScheme.onPrimary MaterialTheme.colorScheme.onPrimary
} else { } else {
Color(0xFF1A1A24) LightOnSurface
} }
} else { } else {
if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) { if (MaterialTheme.colorScheme.onPrimary.luminance() > 0.5f) {
MaterialTheme.colorScheme.onPrimary MaterialTheme.colorScheme.onPrimary
} else { } else {
Color.White DarkOnSurface
} }
} }
Box { Box {
@@ -324,13 +333,13 @@ private fun AssistantBubble(
if (MaterialTheme.colorScheme.onSurface.luminance() < 0.5f) { if (MaterialTheme.colorScheme.onSurface.luminance() < 0.5f) {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
} else { } else {
Color(0xFF1A1A24) LightOnSurface
} }
} else { } else {
if (MaterialTheme.colorScheme.onSurface.luminance() > 0.5f) { if (MaterialTheme.colorScheme.onSurface.luminance() > 0.5f) {
MaterialTheme.colorScheme.onSurface MaterialTheme.colorScheme.onSurface
} else { } else {
Color(0xFFE8E6EE) DarkOnSurface
} }
} }
val clipboard = LocalClipboard.current val clipboard = LocalClipboard.current
@@ -1719,6 +1728,7 @@ fun parseToolOutput(
private fun ExpandedToolContent( private fun ExpandedToolContent(
parsed: ParsedToolData, parsed: ParsedToolData,
contentColor: Color, contentColor: Color,
statusColors: HermesStatusColors,
) { ) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Summary line at top of expanded view // Summary line at top of expanded view
@@ -1753,7 +1763,7 @@ private fun ExpandedToolContent(
text = stringResource(R.string.chat_tool_stderr, it), text = stringResource(R.string.chat_tool_stderr, it),
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
color = StatusRed.copy(alpha = 0.9f), color = statusColors.error.copy(alpha = 0.9f),
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 11.sp, fontSize = 11.sp,
), ),
@@ -1764,7 +1774,7 @@ private fun ExpandedToolContent(
text = stringResource(R.string.chat_tool_execution_error, it), text = stringResource(R.string.chat_tool_execution_error, it),
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
color = StatusRed, color = statusColors.error,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 11.sp, fontSize = 11.sp,
@@ -1777,7 +1787,7 @@ private fun ExpandedToolContent(
text = stringResource(R.string.chat_tool_exit_code, code), text = stringResource(R.string.chat_tool_exit_code, code),
style = style =
MaterialTheme.typography.labelSmall.copy( MaterialTheme.typography.labelSmall.copy(
color = StatusRed, color = statusColors.error,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
), ),
) )
@@ -1854,6 +1864,7 @@ private fun ToolBubble(
var showRawJson by remember { mutableStateOf(false) } var showRawJson by remember { mutableStateOf(false) }
val chipColor = if (isDarkTheme) ToolChipColor else ToolChipColorLight val chipColor = if (isDarkTheme) ToolChipColor else ToolChipColorLight
val contentColor = if (isDarkTheme) Color.White else Color.Black val contentColor = if (isDarkTheme) Color.White else Color.Black
val statusColors = LocalHermesStatusColors.current
val parsed = val parsed =
remember(message.content, message.toolName, message.toolStatus) { remember(message.content, message.toolName, message.toolStatus) {
@@ -1892,7 +1903,7 @@ private fun ToolBubble(
.padding(horizontal = 10.dp, vertical = 6.dp), .padding(horizontal = 10.dp, vertical = 6.dp),
) { ) {
// ── Header row: icon + tool name ── // ── Header row: icon + tool name ──
HeaderRow(message, config, contentColor) HeaderRow(message, config, contentColor, statusColors)
// ── Tool progress preview (tool.progress) ── // ── Tool progress preview (tool.progress) ──
if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) { if (message.toolStatus == ToolStatus.RUNNING && !message.progressPreview.isNullOrEmpty()) {
@@ -1978,7 +1989,7 @@ private fun ToolBubble(
// Clean structured expanded view // Clean structured expanded view
Box { Box {
SelectionContainer { SelectionContainer {
ExpandedToolContent(parsed, contentColor) ExpandedToolContent(parsed, contentColor, statusColors)
} }
CopyButton( CopyButton(
visible = showCopyButton, visible = showCopyButton,
@@ -2048,6 +2059,7 @@ private fun HeaderRow(
message: ChatMessage, message: ChatMessage,
config: ToolDisplayConfig, config: ToolDisplayConfig,
contentColor: Color, contentColor: Color,
statusColors: HermesStatusColors,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -2069,8 +2081,8 @@ private fun HeaderRow(
} }
val tint = val tint =
when (message.toolStatus) { when (message.toolStatus) {
ToolStatus.COMPLETED -> StatusGreen ToolStatus.COMPLETED -> statusColors.success
ToolStatus.FAILED -> StatusRed ToolStatus.FAILED -> statusColors.error
else -> contentColor.copy(alpha = 0.6f) else -> contentColor.copy(alpha = 0.6f)
} }
Icon( Icon(
@@ -2200,8 +2212,14 @@ private fun buildHighlightedString(
text: String, text: String,
query: String, query: String,
isCurrentMatch: Boolean = false, isCurrentMatch: Boolean = false,
statusColors: HermesStatusColors,
): AnnotatedString { ): 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 { return buildAnnotatedString {
var i = 0 var i = 0
while (i < text.length) { while (i < text.length) {
@@ -2216,7 +2234,7 @@ private fun buildHighlightedString(
append(text.substring(i, matchEnd)) append(text.substring(i, matchEnd))
} }
// Append the match highlighted // Append the match highlighted
withStyle(SpanStyle(background = highlightColor, color = Color(0xFF1A1A24))) { withStyle(SpanStyle(background = highlightColor, color = highlightText)) {
append(text.substring(matchEnd, matchEnd + query.length)) append(text.substring(matchEnd, matchEnd + query.length))
} }
i = 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.data.ws.HermesWsClient
import com.m57.hermescontrol.notification.NotificationHelper import com.m57.hermescontrol.notification.NotificationHelper
import com.m57.hermescontrol.theme.LocalHermesStatusColors 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.CredentialWarningBanner
import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.EmptyState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
@@ -358,7 +357,7 @@ fun ChatScreen(
Modifier Modifier
.size(10.dp) .size(10.dp)
.clip(CircleShape) .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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R 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.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"‘’]+""") private val URL_PATTERN = Regex("""https?://[^\s)>\[\]"'‘’]+""")
private val HIGHLIGHT_BG = Color(0xFFFFFF00).copy(alpha = 0.3f)
private val TABLE_COL_WIDTH = 160.dp private val TABLE_COL_WIDTH = 160.dp
private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""") private val FN_DEF_RE = Regex("""^\[\^([^\]]+)\]:\s*(.*)$""")
@@ -84,6 +86,8 @@ fun MarkdownText(
isCurrentMatch: Boolean = false, isCurrentMatch: Boolean = false,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val statusColors = LocalHermesStatusColors.current
val highlights = searchHighlightColors(statusColors)
if (isStreaming) { if (isStreaming) {
Text( Text(
text = text, text = text,
@@ -121,7 +125,7 @@ fun MarkdownText(
Text( Text(
text = text =
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor) parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor, highlights)
}, },
color = textColor, color = textColor,
style = style =
@@ -145,7 +149,14 @@ fun MarkdownText(
Text( Text(
text = text =
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor) parseInline(
block.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
}, },
color = textColor, color = textColor,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -180,7 +191,14 @@ fun MarkdownText(
Text( Text(
text = text =
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor) parseInline(
block.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
}, },
color = textColor, color = textColor,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -203,7 +221,14 @@ fun MarkdownText(
Text( Text(
text = text =
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor) parseInline(
block.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
}, },
color = textColor, color = textColor,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -229,7 +254,14 @@ fun MarkdownText(
Text( Text(
text = text =
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor) parseInline(
block.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
}, },
color = textColor, color = textColor,
style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic), style = MaterialTheme.typography.bodyMedium.copy(fontStyle = FontStyle.Italic),
@@ -283,7 +315,14 @@ fun MarkdownText(
Text( Text(
text = text =
remember(note.text, searchQuery, isCurrentMatch, textColor, linkColor) { remember(note.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(note.text, textColor, searchQuery, isCurrentMatch, linkColor) parseInline(
note.text,
textColor,
searchQuery,
isCurrentMatch,
linkColor,
highlights,
)
}, },
color = textColor, color = textColor,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
@@ -298,7 +337,7 @@ fun MarkdownText(
Text( Text(
text = text =
remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) { remember(block.text, searchQuery, isCurrentMatch, textColor, linkColor) {
parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor) parseInline(block.text, textColor, searchQuery, isCurrentMatch, linkColor, highlights)
}, },
color = textColor, color = textColor,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -663,9 +702,14 @@ internal fun parseInline(
searchQuery: String, searchQuery: String,
isCurrentMatch: Boolean, isCurrentMatch: Boolean,
linkColor: Color, linkColor: Color,
highlights: SearchHighlightColors,
): AnnotatedString { ): AnnotatedString {
val searchHighlightColor = 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 { return buildAnnotatedString {
var i = 0 var i = 0
@@ -732,7 +776,7 @@ internal fun parseInline(
src.startsWith("==", i) -> { src.startsWith("==", i) -> {
val end = src.indexOf("==", i + 2) val end = src.indexOf("==", i + 2)
if (end != -1) { if (end != -1) {
withStyle(SpanStyle(background = HIGHLIGHT_BG)) { withStyle(SpanStyle(background = highlights.markupBackground)) {
append(src.substring(i + 2, end)) append(src.substring(i + 2, end))
} }
i = end + 2 i = end + 2
@@ -881,8 +925,8 @@ internal fun parseInline(
src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> { src.regionMatches(i, searchQuery, 0, searchQuery.length, ignoreCase = true) -> {
withStyle( withStyle(
SpanStyle( SpanStyle(
background = searchHighlightColor, background = searchHighlightColor.first,
color = Color(0xFF1A1A24), color = searchHighlightColor.second,
), ),
) { ) {
append(src.substring(i, i + searchQuery.length)) append(src.substring(i, i + searchQuery.length))
@@ -29,13 +29,13 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
@@ -50,6 +50,7 @@ fun GatewayScreen(
viewModel: GatewayViewModel = viewModel { GatewayViewModel() }, viewModel: GatewayViewModel = viewModel { GatewayViewModel() },
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val statusColors = LocalHermesStatusColors.current
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.loadStatus() viewModel.loadStatus()
@@ -188,8 +189,8 @@ fun GatewayScreen(
enabled = !isRunning && !state.isActionRunning, enabled = !isRunning && !state.isActionRunning,
colors = colors =
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
containerColor = Color(0xFF4CAF50), // Green for start containerColor = statusColors.success,
contentColor = Color.White, contentColor = statusColors.onSuccess,
), ),
) { ) {
Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null) Icon(imageVector = Icons.Filled.PlayArrow, contentDescription = null)
@@ -203,8 +204,8 @@ fun GatewayScreen(
enabled = isRunning && !state.isActionRunning, enabled = isRunning && !state.isActionRunning,
colors = colors =
ButtonDefaults.buttonColors( ButtonDefaults.buttonColors(
containerColor = Color(0xFFF44336), // Red for stop containerColor = statusColors.error,
contentColor = Color.White, contentColor = statusColors.onError,
), ),
) { ) {
Text(stringResource(R.string.gateway_action_stop)) Text(stringResource(R.string.gateway_action_stop))
@@ -270,9 +271,9 @@ fun GatewayScreen(
val stateText = pStatus?.state?.uppercase() ?: "UNKNOWN" val stateText = pStatus?.state?.uppercase() ?: "UNKNOWN"
val badgeColor = val badgeColor =
when (stateText) { when (stateText) {
"RUNNING" -> Color(0xFF4CAF50) "RUNNING" -> statusColors.success
"STOPPED" -> Color(0xFFF44336) "STOPPED" -> statusColors.error
"ERROR" -> Color(0xFFFF9800) "ERROR" -> statusColors.warning
else -> MaterialTheme.colorScheme.onSurfaceVariant else -> MaterialTheme.colorScheme.onSurfaceVariant
} }
Text( Text(
@@ -575,7 +575,7 @@ fun SessionsScreen(
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
strokeWidth = 2.dp, strokeWidth = 2.dp,
color = Color.White, color = statusColors.onError,
) )
} else { } else {
Icon( Icon(
@@ -1,11 +1,39 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.StatusBlue
import com.m57.hermescontrol.theme.StatusBlueContainer
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusGreenContainer
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.theme.StatusRedContainer
import com.m57.hermescontrol.theme.StatusYellow
import com.m57.hermescontrol.theme.StatusYellowContainer
import com.m57.hermescontrol.theme.searchHighlightColors
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
private val DEFAULT_HIGHLIGHTS =
searchHighlightColors(
HermesStatusColors(
success = StatusGreen,
successContainer = StatusGreenContainer,
onSuccess = Color.White,
warning = StatusYellow,
warningContainer = StatusYellowContainer,
onWarning = Color.White,
error = StatusRed,
errorContainer = StatusRedContainer,
onError = Color.White,
info = StatusBlue,
infoContainer = StatusBlueContainer,
onInfo = Color.White,
),
)
/** /**
* Verifies the hand-rolled Markdown parser covers the feature set requested for issue #572 * Verifies the hand-rolled Markdown parser covers the feature set requested for issue #572
* (math excluded by decision). Each test asserts the block/inline structure actually parses — * (math excluded by decision). Each test asserts the block/inline structure actually parses —
@@ -47,7 +75,7 @@ class MarkdownTextFeatureTest {
// 3. STRIKETHROUGH // 3. STRIKETHROUGH
@Test @Test
fun testStrikethrough_parses() { fun testStrikethrough_parses() {
val an = parseInline("~~gone~~", Color.Black, "", false, Color.Blue) val an = parseInline("~~gone~~", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("gone", an.toString()) assertEquals("gone", an.toString())
assertTrue(an.spanStyles.any { it.item.textDecoration != null }) assertTrue(an.spanStyles.any { it.item.textDecoration != null })
} }
@@ -55,7 +83,7 @@ class MarkdownTextFeatureTest {
// 4. BOLD + ITALIC in same word (***bolditalic***) // 4. BOLD + ITALIC in same word (***bolditalic***)
@Test @Test
fun testBoldItalic_combined() { fun testBoldItalic_combined() {
val an = parseInline("***both***", Color.Black, "", false, Color.Blue) val an = parseInline("***both***", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("both", an.toString()) assertEquals("both", an.toString())
assertTrue(an.spanStyles.isNotEmpty()) assertTrue(an.spanStyles.isNotEmpty())
} }
@@ -128,7 +156,7 @@ class MarkdownTextFeatureTest {
// 9. HIGHLIGHT // 9. HIGHLIGHT
@Test @Test
fun testHighlight_parses() { fun testHighlight_parses() {
val an = parseInline("==mark==", Color.Black, "", false, Color.Blue) val an = parseInline("==mark==", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("mark", an.toString()) assertEquals("mark", an.toString())
assertTrue(an.spanStyles.any { it.item.background != null }) assertTrue(an.spanStyles.any { it.item.background != null })
} }
@@ -136,16 +164,17 @@ class MarkdownTextFeatureTest {
// 10. SUPERSCRIPT / SUBSCRIPT // 10. SUPERSCRIPT / SUBSCRIPT
@Test @Test
fun testSuperscriptAndSubscript_parse() { fun testSuperscriptAndSubscript_parse() {
val sup = parseInline("x^2^", Color.Black, "", false, Color.Blue) val sup = parseInline("x^2^", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertTrue(sup.spanStyles.any { it.item.baselineShift != null }) assertTrue(sup.spanStyles.any { it.item.baselineShift != null })
val sub = parseInline("H~2~O", Color.Black, "", false, Color.Blue) val sub = parseInline("H~2~O", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertTrue(sub.spanStyles.any { it.item.baselineShift != null }) assertTrue(sub.spanStyles.any { it.item.baselineShift != null })
} }
// 11. KEYBOARD KEYS <kbd> // 11. KEYBOARD KEYS <kbd>
@Test @Test
fun testKbd_parses() { fun testKbd_parses() {
val an = parseInline("Press <kbd>Ctrl</kbd>+<kbd>C</kbd>", Color.Black, "", false, Color.Blue) val an =
parseInline("Press <kbd>Ctrl</kbd>+<kbd>C</kbd>", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertTrue(an.toString().contains("Ctrl")) assertTrue(an.toString().contains("Ctrl"))
assertTrue(an.toString().contains("C")) assertTrue(an.toString().contains("C"))
assertTrue(an.spanStyles.any { it.item.fontFamily != null }) assertTrue(an.spanStyles.any { it.item.fontFamily != null })
@@ -161,7 +190,7 @@ class MarkdownTextFeatureTest {
// --- regression: streaming gate handled in composable, parser must not split inline code --- // --- regression: streaming gate handled in composable, parser must not split inline code ---
@Test @Test
fun testInlineCode_preserved() { fun testInlineCode_preserved() {
val an = parseInline("use `val x = 1` here", Color.Black, "", false, Color.Blue) val an = parseInline("use `val x = 1` here", Color.Black, "", false, Color.Blue, DEFAULT_HIGHLIGHTS)
assertEquals("use val x = 1 here", an.toString()) assertEquals("use val x = 1 here", an.toString())
assertTrue(an.spanStyles.any { it.item.fontFamily != null }) assertTrue(an.spanStyles.any { it.item.fontFamily != null })
} }