refactor(android): collapse the chat's second color palette into ClawTheme (#125020)

* refactor(android): consolidate chat palette

* refactor(android): fold the font family into ClawTheme
This commit is contained in:
Peter Steinberger
2026-08-16 21:31:27 -07:00
committed by GitHub
parent f7a8638282
commit d095113698
8 changed files with 110 additions and 311 deletions
@@ -289,8 +289,8 @@ internal object AndroidScreenshotFixture {
add(
chatMessage(
"assistant",
"The main thread asks for a regression test around session restore, and the second one wants the new " +
"config key documented before merge. Both are small; I can draft patches for each if you want.",
"The release check is ready:\n\n```kotlin\nval ready = lint && tests\n```\n\n" +
"Review https://openclaw.ai before tagging.",
1_783_555_200_000,
),
)
@@ -343,6 +343,13 @@ internal object AndroidScreenshotFixture {
put("contextTokens", JsonPrimitive(200_000))
},
)
put(
"inFlightRun",
buildJsonObject {
put("runId", JsonPrimitive("android-screenshot-active-run"))
put("text", JsonPrimitive(""))
},
)
}.toString()
private fun chatMessage(
@@ -1,205 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.R
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
internal data class MobileColors(
val surface: Color,
val surfaceStrong: Color,
val cardSurface: Color,
val border: Color,
val borderStrong: Color,
val text: Color,
val textSecondary: Color,
val textTertiary: Color,
val accent: Color,
val accentSoft: Color,
val accentBorderStrong: Color,
val success: Color,
val successSoft: Color,
val warning: Color,
val warningSoft: Color,
val danger: Color,
val dangerSoft: Color,
val codeBg: Color,
val codeText: Color,
val codeBorder: Color,
val codeAccent: Color,
// Syntax token colors; every value must keep >= 4.5:1 contrast against codeBg in its theme.
val codeKeyword: Color,
val codeString: Color,
val codeComment: Color,
val codeNumber: Color,
val chipBorderConnected: Color,
val chipBorderConnecting: Color,
val chipBorderWarning: Color,
val chipBorderError: Color,
)
internal fun lightMobileColors() =
MobileColors(
surface = Color(0xFFFAFBFC),
surfaceStrong = Color(0xFFEFF3F8),
cardSurface = Color(0xFFFFFFFF),
border = Color(0xFFDDE3EC),
borderStrong = Color(0xFFC7D0DC),
text = Color(0xFF16181D),
textSecondary = Color(0xFF505B6A),
textTertiary = Color(0xFF8E98A7),
accent = Color(0xFF1B5ACB),
accentSoft = Color(0xFFEAF2FF),
accentBorderStrong = Color(0xFF174CA9),
success = Color(0xFF287F52),
successSoft = Color(0xFFEAF7F0),
warning = Color(0xFFAF7418),
warningSoft = Color(0xFFFFF4DF),
danger = Color(0xFFC94343),
dangerSoft = Color(0xFFFFECEC),
codeBg = Color(0xFFEFF3F8),
codeText = Color(0xFF172033),
codeBorder = Color(0xFFD7DDE7),
codeAccent = Color(0xFF287F52),
codeKeyword = Color(0xFF1B5ACB),
codeString = Color(0xFF1F6B45),
codeComment = Color(0xFF505B6A),
codeNumber = Color(0xFF8F5D10),
chipBorderConnected = Color(0xFFCFEBD8),
chipBorderConnecting = Color(0xFFD5E2FA),
chipBorderWarning = Color(0xFFEED8B8),
chipBorderError = Color(0xFFF3C8C8),
)
internal fun darkMobileColors() =
MobileColors(
surface = Color(0xFF1A1C20),
surfaceStrong = Color(0xFF24262B),
cardSurface = Color(0xFF1E2024),
border = Color(0xFF2E3038),
borderStrong = Color(0xFF3A3D46),
text = Color(0xFFE4E5EA),
textSecondary = Color(0xFFA0A6B4),
textTertiary = Color(0xFF6B7280),
accent = Color(0xFF6EA8FF),
accentSoft = Color(0xFF1A2A44),
accentBorderStrong = Color(0xFF5B93E8),
success = Color(0xFF5FBB85),
successSoft = Color(0xFF152E22),
warning = Color(0xFFE8A844),
warningSoft = Color(0xFF2E2212),
danger = Color(0xFFE87070),
dangerSoft = Color(0xFF2E1616),
codeBg = Color(0xFF111317),
codeText = Color(0xFFE8EAEE),
codeBorder = Color(0xFF2B2E35),
codeAccent = Color(0xFF3FC97A),
codeKeyword = Color(0xFF6EA8FF),
codeString = Color(0xFF3FC97A),
codeComment = Color(0xFFA0A6B4),
codeNumber = Color(0xFFE8A844),
chipBorderConnected = Color(0xFF1E4A30),
chipBorderConnecting = Color(0xFF1E3358),
chipBorderWarning = Color(0xFF3E3018),
chipBorderError = Color(0xFF3E1E1E),
)
// Defaulting to light tokens keeps previews/tests usable when a screen forgets to
// provide the app theme; production roots override this composition local.
internal val LocalMobileColors = staticCompositionLocalOf { lightMobileColors() }
internal object MobileColorsAccessor {
val current: MobileColors
@Composable get() = LocalMobileColors.current
}
// Keep these accessors while screens migrate to `MobileColorsAccessor.current`.
// Each getter must stay composable so callers always read the active theme.
internal val mobileSurface: Color @Composable get() = LocalMobileColors.current.surface
internal val mobileCardSurface: Color @Composable get() = LocalMobileColors.current.cardSurface
internal val mobileBorder: Color @Composable get() = LocalMobileColors.current.border
internal val mobileBorderStrong: Color @Composable get() = LocalMobileColors.current.borderStrong
internal val mobileText: Color @Composable get() = LocalMobileColors.current.text
internal val mobileTextSecondary: Color @Composable get() = LocalMobileColors.current.textSecondary
internal val mobileTextTertiary: Color @Composable get() = LocalMobileColors.current.textTertiary
internal val mobileAccent: Color @Composable get() = LocalMobileColors.current.accent
internal val mobileAccentSoft: Color @Composable get() = LocalMobileColors.current.accentSoft
internal val mobileAccentBorderStrong: Color @Composable get() = LocalMobileColors.current.accentBorderStrong
internal val mobileWarning: Color @Composable get() = LocalMobileColors.current.warning
internal val mobileWarningSoft: Color @Composable get() = LocalMobileColors.current.warningSoft
internal val mobileDanger: Color @Composable get() = LocalMobileColors.current.danger
internal val mobileDangerSoft: Color @Composable get() = LocalMobileColors.current.dangerSoft
internal val mobileCodeBg: Color @Composable get() = LocalMobileColors.current.codeBg
internal val mobileCodeText: Color @Composable get() = LocalMobileColors.current.codeText
internal val mobileCodeBorder: Color @Composable get() = LocalMobileColors.current.codeBorder
// Build the page backdrop from semantic surfaces so light/dark palettes keep
// their contrast relationship without duplicating raw color stops.
internal val mobileBackgroundGradient: Brush
@Composable get() {
val colors = LocalMobileColors.current
return Brush.verticalGradient(
listOf(
colors.surface,
colors.surfaceStrong,
colors.surfaceStrong,
),
)
}
internal val mobileFontFamily =
FontFamily(
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),
Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium),
Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold),
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
)
internal val mobileHeadline =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 22.sp,
letterSpacing = (-0.1).sp,
)
internal val mobileBody =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
lineHeight = 22.sp,
)
internal val mobileCallout =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
)
internal val mobileCaption1 =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.2.sp,
)
internal val mobileCaption2 =
TextStyle(
fontFamily = mobileFontFamily,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
letterSpacing = 0.4.sp,
)
@@ -17,7 +17,7 @@ import androidx.core.view.WindowCompat
internal val LocalResolvedAppearanceIsDark = staticCompositionLocalOf { false }
/**
* App theme wrapper that installs dynamic Material colors and legacy mobile color tokens.
* App theme wrapper that resolves the requested appearance for system surfaces and child themes.
*/
@Composable
fun OpenClawTheme(
@@ -27,12 +27,10 @@ fun OpenClawTheme(
val context = LocalContext.current
val isDark = themeMode.isDark(systemDark = isSystemInDarkTheme())
val colorScheme = if (isDark) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
val mobileColors = if (isDark) darkMobileColors() else lightMobileColors()
OpenClawSystemBarAppearance(lightAppearance = !isDark)
CompositionLocalProvider(
LocalMobileColors provides mobileColors,
LocalResolvedAppearanceIsDark provides isDark,
) {
MaterialTheme(colorScheme = colorScheme, content = content)
@@ -3,12 +3,6 @@ package ai.openclaw.app.ui.chat
import ai.openclaw.app.chat.CHAT_IMAGE_MAX_BASE64_CHARS
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.ui.mobileAccent
import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCaption1
import ai.openclaw.app.ui.mobileCodeBg
import ai.openclaw.app.ui.mobileCodeText
import ai.openclaw.app.ui.mobileTextSecondary
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -120,7 +114,12 @@ fun ChatMarkdown(
) {
val blocks = remember(text, isStreaming) { segmentChatMarkdown(text, isStreaming) }
val inlineStyles =
InlineStyles(inlineCodeBg = mobileCodeBg, inlineCodeColor = mobileCodeText, linkColor = mobileAccent, baseCallout = mobileCallout)
InlineStyles(
inlineCodeBg = ClawTheme.colors.codeBg,
inlineCodeColor = ClawTheme.colors.codeText,
linkColor = ClawTheme.colors.accent,
baseCallout = ClawTheme.type.body,
)
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
for (block in blocks) {
@@ -225,7 +224,7 @@ private fun RenderCommonMarkBlock(
Modifier
.width(2.dp)
.fillMaxHeight()
.background(mobileTextSecondary.copy(alpha = 0.35f)),
.background(ClawTheme.colors.textMuted.copy(alpha = 0.35f)),
)
Column(
modifier = Modifier.weight(1f),
@@ -272,7 +271,7 @@ private fun RenderCommonMarkBlock(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(mobileTextSecondary.copy(alpha = 0.25f)),
.background(ClawTheme.colors.textMuted.copy(alpha = 0.25f)),
)
}
is HtmlBlock -> {
@@ -290,7 +289,7 @@ private fun RenderLiteralHtml(
if (literal.isNotEmpty()) {
Text(
text = literal,
style = mobileCallout.copy(fontFamily = FontFamily.Monospace),
style = ClawTheme.type.body.copy(fontFamily = FontFamily.Monospace),
color = textColor,
)
}
@@ -326,11 +325,11 @@ private fun RenderMarkdownDisclosure(
) {
Text(
text = if (isExpanded) "" else "",
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
style = ClawTheme.type.body.copy(fontWeight = FontWeight.SemiBold),
)
Text(
text = summary,
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
style = ClawTheme.type.body.copy(fontWeight = FontWeight.SemiBold),
color = textColor,
)
}
@@ -499,7 +498,7 @@ private fun RenderTableBlock(
Modifier
.fillMaxWidth()
.horizontalScroll(scrollState)
.border(1.dp, mobileTextSecondary.copy(alpha = 0.25f)),
.border(1.dp, ClawTheme.colors.textMuted.copy(alpha = 0.25f)),
) {
for (row in rows) {
Row(
@@ -509,11 +508,11 @@ private fun RenderTableBlock(
val cell = row.cells.getOrNull(index) ?: AnnotatedString("")
Text(
text = cell,
style = if (row.isHeader) mobileCaption1.copy(fontWeight = FontWeight.SemiBold) else inlineStyles.baseCallout,
style = if (row.isHeader) ClawTheme.type.caption.copy(fontWeight = FontWeight.SemiBold) else inlineStyles.baseCallout,
color = textColor,
modifier =
Modifier
.border(1.dp, mobileTextSecondary.copy(alpha = 0.22f))
.border(1.dp, ClawTheme.colors.textMuted.copy(alpha = 0.22f))
.padding(horizontal = 8.dp, vertical = 6.dp)
.width(160.dp),
)
@@ -1213,8 +1212,8 @@ private fun InlineBase64Image(
Text(
text = nativeString("Image unavailable"),
modifier = Modifier.padding(vertical = 2.dp),
style = mobileCaption1,
color = mobileTextSecondary,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
@@ -9,26 +9,9 @@ import ai.openclaw.app.chat.normalizeVisibleChatMessageRole
import ai.openclaw.app.gateway.GatewayLoadedImage
import ai.openclaw.app.i18n.nativeString
import ai.openclaw.app.i18n.nativeStringResource
import ai.openclaw.app.ui.MobileColorsAccessor
import ai.openclaw.app.ui.design.ClawTheme
import ai.openclaw.app.ui.image.RemoteImageResult
import ai.openclaw.app.ui.image.safeRemoteImageStore
import ai.openclaw.app.ui.mobileAccent
import ai.openclaw.app.ui.mobileAccentBorderStrong
import ai.openclaw.app.ui.mobileAccentSoft
import ai.openclaw.app.ui.mobileBorder
import ai.openclaw.app.ui.mobileBorderStrong
import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.mobileCaption1
import ai.openclaw.app.ui.mobileCaption2
import ai.openclaw.app.ui.mobileCardSurface
import ai.openclaw.app.ui.mobileCodeBg
import ai.openclaw.app.ui.mobileCodeBorder
import ai.openclaw.app.ui.mobileCodeText
import ai.openclaw.app.ui.mobileDanger
import ai.openclaw.app.ui.mobileText
import ai.openclaw.app.ui.mobileTextSecondary
import ai.openclaw.app.ui.mobileWarning
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -94,8 +77,8 @@ private fun ChatBubbleContainer(
) {
Surface(
shape = RoundedCornerShape(12.dp),
border = BorderStroke(1.dp, borderColor ?: if (user) mobileAccentBorderStrong else mobileBorderStrong),
color = if (user) mobileAccentSoft else mobileCardSurface,
border = BorderStroke(1.dp, borderColor ?: if (user) ClawTheme.colors.accentBorder else ClawTheme.colors.borderStrong),
color = if (user) ClawTheme.colors.accentSoft else ClawTheme.colors.surfaceRaised,
tonalElevation = 0.dp,
shadowElevation = 0.dp,
modifier =
@@ -147,8 +130,8 @@ private fun ChatLinkPreview(
Surface(
onClick = { expanded = true },
shape = RoundedCornerShape(8.dp),
color = mobileCardSurface,
border = BorderStroke(1.dp, mobileBorder),
color = ClawTheme.colors.surfaceRaised,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
@@ -157,8 +140,8 @@ private fun ChatLinkPreview(
) {
Text(
text = nativeString("Preview · \$domain", domain),
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
color = mobileTextSecondary,
style = ClawTheme.type.caption.copy(fontWeight = FontWeight.SemiBold),
color = ClawTheme.colors.textMuted,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -166,7 +149,7 @@ private fun ChatLinkPreview(
androidx.compose.material3.Icon(
imageVector = Icons.Default.ExpandMore,
contentDescription = nativeString("Expand link preview"),
tint = mobileTextSecondary,
tint = ClawTheme.colors.textMuted,
)
}
}
@@ -190,8 +173,8 @@ private fun ChatLinkPreview(
Surface(
onClick = { uriHandler.openUri(url) },
shape = cardShape,
color = mobileCardSurface,
border = BorderStroke(1.dp, mobileBorder),
color = ClawTheme.colors.surfaceRaised,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.fillMaxWidth()) {
previewImage?.let { image ->
@@ -206,16 +189,16 @@ private fun ChatLinkPreview(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(3.dp),
) {
Text(domain, style = mobileCaption2, color = mobileTextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(domain, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
when (val preview = result) {
null -> Text(nativeString("Loading preview…"), style = mobileCaption1, color = mobileTextSecondary)
LinkPreviewResult.Failed -> Text(nativeString("No preview available"), style = mobileCallout, color = mobileTextSecondary)
null -> Text(nativeString("Loading preview…"), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
LinkPreviewResult.Failed -> Text(nativeString("No preview available"), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
is LinkPreviewResult.Loaded -> {
preview.metadata.title?.let { title ->
Text(
text = title,
style = mobileCallout.copy(fontWeight = FontWeight.SemiBold),
color = mobileText,
style = ClawTheme.type.body.copy(fontWeight = FontWeight.SemiBold),
color = ClawTheme.colors.text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
@@ -223,8 +206,8 @@ private fun ChatLinkPreview(
preview.metadata.description?.let { description ->
Text(
text = description,
style = mobileCaption1,
color = mobileTextSecondary,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -262,17 +245,17 @@ fun ChatTypingIndicatorBubble(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
WorkingClawIcon(runKey = runKey, color = mobileAccent)
WorkingClawIcon(runKey = runKey, color = ClawTheme.colors.accent)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(formatLocalizedChatDurationCompact(elapsedMs), style = mobileCallout, color = mobileTextSecondary)
Text(formatLocalizedChatDurationCompact(elapsedMs), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
tokens?.let {
Text(nativeStringResource("·"), style = mobileCallout, color = mobileTextSecondary)
Text(it, style = mobileCallout, color = mobileTextSecondary)
Text(nativeStringResource("·"), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
Text(it, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
phrase?.let { Text(nativeStringResource("· \$phrase", it), style = mobileCallout, color = mobileTextSecondary) }
phrase?.let { Text(nativeStringResource("· \$phrase", it), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted) }
}
}
}
@@ -287,7 +270,7 @@ fun ChatOutboxBubble(
onDelete: () -> Unit,
) {
val failed = item.status == ChatOutboxStatus.Failed
val statusColor = if (failed) mobileDanger else mobileWarning
val statusColor = if (failed) ClawTheme.colors.danger else ClawTheme.colors.warning
val statusLabel =
when (item.status) {
ChatOutboxStatus.Queued -> nativeString("Queued — sends when reconnected")
@@ -314,13 +297,13 @@ fun ChatOutboxBubble(
borderColor = statusColor.copy(alpha = 0.6f),
) {
if (item.text.isNotBlank()) {
ChatMarkdown(text = item.text, textColor = mobileText)
ChatMarkdown(text = item.text, textColor = ClawTheme.colors.text)
}
item.attachments.forEach { attachment ->
Text(
text = nativeString("📎 \${attachment.fileName}", attachment.fileName),
style = mobileCaption1,
color = mobileTextSecondary,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
Row(
@@ -329,17 +312,17 @@ fun ChatOutboxBubble(
) {
Text(
text = statusLabel,
style = mobileCaption1,
style = ClawTheme.type.caption,
color = statusColor,
modifier = Modifier.weight(1f),
)
if (failed && retryEnabled) {
ChatOutboxAction(label = nativeString("Retry"), color = mobileAccent, onClick = onRetry)
ChatOutboxAction(label = nativeString("Retry"), color = ClawTheme.colors.accent, onClick = onRetry)
}
// Sending rows are mid-dispatch and accepted rows may already be delivered; both stay
// action-free until reconciliation resolves them, so a delete can never race a send.
if (item.status == ChatOutboxStatus.Queued || failed) {
ChatOutboxAction(label = nativeString("Delete"), color = mobileTextSecondary, onClick = onDelete)
ChatOutboxAction(label = nativeString("Delete"), color = ClawTheme.colors.textMuted, onClick = onDelete)
}
}
}
@@ -360,7 +343,7 @@ private fun ChatOutboxAction(
) {
Text(
text = label,
style = mobileCaption1.copy(fontWeight = FontWeight.SemiBold),
style = ClawTheme.type.caption.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
)
}
@@ -377,7 +360,7 @@ internal fun ChatBase64Image(
if (image != null) {
ChatImagePreview(image = image, description = mimeType ?: nativeString("Attachment"), stateKey = base64)
} else if (imageState.failed) {
Text(nativeString("Unsupported attachment"), style = mobileCaption1, color = mobileTextSecondary)
Text(nativeString("Unsupported attachment"), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
}
}
@@ -414,23 +397,23 @@ internal fun ChatManagedImage(
Surface(
onClick = { retryGeneration += 1 },
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, mobileBorder),
color = mobileCardSurface,
border = BorderStroke(1.dp, ClawTheme.colors.border),
color = ClawTheme.colors.surfaceRaised,
modifier = Modifier.fillMaxWidth(),
) {
Text(
nativeString("Image unavailable · Tap to retry"),
modifier = Modifier.padding(12.dp),
style = mobileCaption1,
color = mobileTextSecondary,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
else ->
Text(
nativeString("Loading image…"),
modifier = Modifier.padding(12.dp),
style = mobileCaption1,
color = mobileTextSecondary,
style = ClawTheme.type.caption,
color = ClawTheme.colors.textMuted,
)
}
}
@@ -445,8 +428,8 @@ private fun ChatImagePreview(
Surface(
onClick = { previewVisible = true },
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, mobileBorder),
color = mobileCardSurface,
border = BorderStroke(1.dp, ClawTheme.colors.border),
color = ClawTheme.colors.surfaceRaised,
modifier = Modifier.fillMaxWidth(),
) {
Box {
@@ -515,14 +498,14 @@ fun ChatCodeBlock(
isComplete: Boolean = true,
) {
val display = code.trimEnd()
// Token colors come from the theme's code palette so light/dark both keep readable contrast.
val palette = MobileColorsAccessor.current
// Syntax roles reuse semantic colors that keep at least 4.5:1 contrast against codeBg;
// changing these mappings can make highlighted code less readable than plain code.
val tokenColors =
CodeTokenColors(
keyword = palette.codeKeyword,
string = palette.codeString,
comment = palette.codeComment,
number = palette.codeNumber,
keyword = ClawTheme.colors.accent,
string = ClawTheme.colors.success,
comment = ClawTheme.colors.textMuted,
number = ClawTheme.colors.danger,
)
// Keyed on content: streaming re-renders of unchanged blocks reuse the tokenized result,
// and still-open fences stay plain until the closing fence arrives.
@@ -532,23 +515,23 @@ fun ChatCodeBlock(
}
Surface(
shape = RoundedCornerShape(8.dp),
color = mobileCodeBg,
border = BorderStroke(1.dp, mobileCodeBorder),
color = ClawTheme.colors.codeBg,
border = BorderStroke(1.dp, ClawTheme.colors.codeBorder),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (!language.isNullOrBlank()) {
Text(
text = language.uppercase(Locale.US),
style = mobileCaption2.copy(letterSpacing = 0.4.sp),
color = mobileTextSecondary,
style = ClawTheme.type.caption.copy(letterSpacing = 0.4.sp),
color = ClawTheme.colors.textMuted,
)
}
Text(
text = highlighted,
fontFamily = FontFamily.Monospace,
style = mobileCallout,
color = mobileCodeText,
style = ClawTheme.type.body,
color = ClawTheme.colors.codeText,
)
}
}
@@ -63,7 +63,6 @@ import ai.openclaw.app.ui.design.agentAvatarSource
import ai.openclaw.app.ui.gatewayDiagnosticsEndpoint
import ai.openclaw.app.ui.gatewayStatusForDisplay
import ai.openclaw.app.ui.localizedUppercase
import ai.openclaw.app.ui.mobileCallout
import ai.openclaw.app.ui.relativeSessionTime
import ai.openclaw.app.ui.rememberSystemAnimationsEnabled
import android.os.SystemClock
@@ -1922,7 +1921,7 @@ private fun ChatUserMessageText(
if (preview != null && !expanded) {
Text(
text = preview,
style = mobileCallout,
style = ClawTheme.type.body,
color = ClawTheme.colors.text,
)
} else {
@@ -1944,7 +1943,7 @@ private fun ChatUserMessageText(
) {
Text(
text = toggleLabel,
style = mobileCallout.copy(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
style = ClawTheme.type.body.copy(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
@@ -1,9 +1,6 @@
package ai.openclaw.app.ui.design
import ai.openclaw.app.ui.LocalMobileColors
import ai.openclaw.app.ui.darkMobileColors
import ai.openclaw.app.ui.lightMobileColors
import ai.openclaw.app.ui.mobileFontFamily
import ai.openclaw.app.R
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
@@ -16,12 +13,21 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
internal val clawFontFamily =
FontFamily(
Font(resId = R.font.manrope_400_regular, weight = FontWeight.Normal),
Font(resId = R.font.manrope_500_medium, weight = FontWeight.Medium),
Font(resId = R.font.manrope_600_semibold, weight = FontWeight.SemiBold),
Font(resId = R.font.manrope_700_bold, weight = FontWeight.Bold),
)
/**
* App color tokens consumed by ClawTheme and bridged into Material components.
*/
@@ -31,6 +37,7 @@ internal data class ClawColors(
val surface: Color,
val surfaceRaised: Color,
val surfacePressed: Color,
val accent: Color,
val accentSoft: Color,
val accentBorder: Color,
val border: Color,
@@ -46,6 +53,9 @@ internal data class ClawColors(
val warningSoft: Color,
val danger: Color,
val dangerSoft: Color,
val codeBg: Color,
val codeText: Color,
val codeBorder: Color,
)
/**
@@ -97,6 +107,7 @@ private val ClawDarkColors =
surface = Color(0xFF0A0A0A),
surfaceRaised = Color(0xFF111111),
surfacePressed = Color(0xFF1A1A1A),
accent = Color(0xFF6EA8FF),
accentSoft = Color(0xFF1A2A44),
accentBorder = Color(0xFF5B93E8),
border = Color(0xFF242424),
@@ -112,6 +123,9 @@ private val ClawDarkColors =
warningSoft = Color(0xFF2B2412),
danger = Color(0xFFFF6B6B),
dangerSoft = Color(0xFF2C1414),
codeBg = Color(0xFF111317),
codeText = Color(0xFFE8EAEE),
codeBorder = Color(0xFF2B2E35),
)
private val ClawLightColors =
@@ -120,6 +134,7 @@ private val ClawLightColors =
surface = Color(0xFFFFFEFB),
surfaceRaised = Color(0xFFFFFFFF),
surfacePressed = Color(0xFFE9EDF3),
accent = Color(0xFF1B5ACB),
accentSoft = Color(0xFFEAF2FF),
accentBorder = Color(0xFF174CA9),
border = Color(0xFFDDE3EC),
@@ -135,12 +150,15 @@ private val ClawLightColors =
warningSoft = Color(0xFFFFF3DC),
danger = Color(0xFFB82929),
dangerSoft = Color(0xFFFFE9E9),
codeBg = Color(0xFFEFF3F8),
codeText = Color(0xFF172033),
codeBorder = Color(0xFFD7DDE7),
)
private val LocalClawColors = staticCompositionLocalOf { ClawDarkColors }
private val LocalClawSpacing = staticCompositionLocalOf { ClawSpacing() }
private val LocalClawRadii = staticCompositionLocalOf { ClawRadii() }
private val LocalClawTypography = staticCompositionLocalOf { clawTypography(mobileFontFamily) }
private val LocalClawTypography = staticCompositionLocalOf { clawTypography(clawFontFamily) }
/**
* Composition-local access point for OpenClaw Android design tokens.
@@ -176,12 +194,10 @@ internal fun ClawDesignTheme(
content: @Composable () -> Unit,
) {
val colors = if (dark) ClawDarkColors else ClawLightColors
val mobileColors = if (dark) darkMobileColors() else lightMobileColors()
val typography = clawTypography(mobileFontFamily)
val typography = clawTypography(clawFontFamily)
CompositionLocalProvider(
LocalClawColors provides colors,
LocalMobileColors provides mobileColors,
LocalClawSpacing provides ClawSpacing(),
LocalClawRadii provides ClawRadii(),
LocalClawTypography provides typography,
@@ -99,12 +99,11 @@ class AndroidScreenshotFixtureTest {
@Test
fun providesDeterministicChatHistory() {
val messages =
val history =
json
.parseToJsonElement(AndroidScreenshotFixture.request("chat.history", null))
.jsonObject["messages"]
?.jsonArray
.orEmpty()
.jsonObject
val messages = history["messages"]?.jsonArray.orEmpty()
assertEquals(
listOf(
@@ -120,8 +119,8 @@ class AndroidScreenshotFixtureTest {
listOf("user", "Summarize the open review feedback for me.", "1783555140000"),
listOf(
"assistant",
"The main thread asks for a regression test around session restore, and the second one wants the new " +
"config key documented before merge. Both are small; I can draft patches for each if you want.",
"The release check is ready:\n\n```kotlin\nval ready = lint && tests\n```\n\n" +
"Review https://openclaw.ai before tagging.",
"1783555200000",
),
listOf("system", "Compaction", "1783555220000"),
@@ -157,6 +156,9 @@ class AndroidScreenshotFixtureTest {
val reset = messages[7].jsonObject["__openclaw"]?.jsonObject
assertEquals("reset", reset?.get("kind")?.jsonPrimitive?.content)
assertEquals("android-screenshot-reset", reset?.get("id")?.jsonPrimitive?.content)
val inFlightRun = history["inFlightRun"]?.jsonObject
assertEquals("android-screenshot-active-run", inFlightRun?.get("runId")?.jsonPrimitive?.content)
assertEquals("", inFlightRun?.get("text")?.jsonPrimitive?.content)
}
@Test