feat: extract compose design system

This commit is contained in:
Hermes Agent
2026-07-24 09:44:22 +00:00
parent 26bff1174d
commit 9ed096997c
8 changed files with 806 additions and 350 deletions
@@ -0,0 +1,120 @@
package cloud.molberg.hermesmobile
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import cloud.molberg.hermesmobile.design.CodeCard
import cloud.molberg.hermesmobile.design.HermesMobileTheme
import cloud.molberg.hermesmobile.design.HermesTheme
import cloud.molberg.hermesmobile.design.ListRow
import cloud.molberg.hermesmobile.design.MessageBubble
import cloud.molberg.hermesmobile.design.NoticeCard
import cloud.molberg.hermesmobile.design.PrimaryButton
import cloud.molberg.hermesmobile.design.ScreenList
import cloud.molberg.hermesmobile.design.SectionHeader
import cloud.molberg.hermesmobile.design.TypingIndicator
@Preview(name = "Small Phone Light", widthDp = 320, heightDp = 640, showBackground = true)
@Composable
fun SmallPhoneLightPreview() {
HermesPreviewSurface(darkTheme = false) {
SampleChatThread()
}
}
@Preview(name = "Normal Phone Dark", widthDp = 393, heightDp = 852, uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
fun NormalPhoneDarkPreview() {
HermesPreviewSurface(darkTheme = true) {
SampleHomeList()
}
}
@Preview(name = "Tablet Width Light", widthDp = 840, heightDp = 900, showBackground = true)
@Composable
fun TabletWidthLightPreview() {
HermesPreviewSurface(darkTheme = false) {
Row(
Modifier
.fillMaxSize()
.padding(HermesTheme.spacing.lg),
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.lg)
) {
Column(Modifier.weight(0.42f)) { SampleHomeList() }
Column(Modifier.weight(0.58f)) { SampleChatThread() }
}
}
}
@Preview(name = "Light Theme Components", widthDp = 393, heightDp = 700, showBackground = true)
@Composable
fun LightThemeComponentsPreview() {
HermesPreviewSurface(darkTheme = false) {
SampleComponentStack()
}
}
@Preview(name = "Dark Theme Components", widthDp = 393, heightDp = 700, uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true)
@Composable
fun DarkThemeComponentsPreview() {
HermesPreviewSurface(darkTheme = true) {
SampleComponentStack()
}
}
@Composable
private fun HermesPreviewSurface(darkTheme: Boolean, content: @Composable () -> Unit) {
HermesMobileTheme(darkTheme = darkTheme) {
Surface(Modifier.fillMaxSize(), color = HermesTheme.colors.background) {
content()
}
}
}
@Composable
private fun SampleHomeList() {
ScreenList {
PrimaryButton("New Chat", Icons.Filled.Add, Modifier.fillMaxWidth()) {}
SectionHeader("Conversations", "2 active threads")
ListRow(Icons.Filled.PlayArrow, "Release checklist", "Hermes is working...") {}
ListRow(Icons.Filled.Folder, "Workspace cleanup", "Updated docs and Android checks") {}
SectionHeader("Status", "Companion and local defaults")
NoticeCard("Companion is reachable.")
NoticeCard("Hermes CLI was not found on PATH.", danger = true)
}
}
@Composable
private fun SampleChatThread() {
ScreenList {
SectionHeader("Chat", "Native conversation surface")
MessageBubble("You", "Summarize the current Android milestone and keep the output concise.")
MessageBubble("Hermes", "F1 extracts reusable theme tokens, shared surfaces, compact rows, controls, and previewable chat components.")
MessageBubble("Error", "Companion returned HTTP 401.")
TypingIndicator()
}
}
@Composable
private fun SampleComponentStack() {
ScreenList {
SectionHeader("Command", "Run a shell command on the companion workspace.")
PrimaryButton("Run command", Icons.Filled.PlayArrow) {}
CodeCard("$ ./gradlew :app:assembleDebug\nBUILD SUCCESSFUL")
SectionHeader("Workspace", "Rows preserve contrast and tap targets.")
ListRow(Icons.Filled.Folder, "Files", "Browse, read, and edit workspace files") {}
NoticeCard("Settings saved locally.")
}
}
@@ -16,16 +16,12 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@@ -34,20 +30,15 @@ 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
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Palette
import androidx.compose.material.icons.filled.PlayArrow
@@ -58,16 +49,30 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Terminal
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.material.icons.outlined.Article
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import cloud.molberg.hermesmobile.design.AppTextField
import cloud.molberg.hermesmobile.design.CodeCard
import cloud.molberg.hermesmobile.design.DetailList
import cloud.molberg.hermesmobile.design.EmptyState
import cloud.molberg.hermesmobile.design.FieldLabel
import cloud.molberg.hermesmobile.design.FileRow
import cloud.molberg.hermesmobile.design.FlatCard
import cloud.molberg.hermesmobile.design.HermesMobileTheme
import cloud.molberg.hermesmobile.design.HermesTheme
import cloud.molberg.hermesmobile.design.ListRow
import cloud.molberg.hermesmobile.design.MessageBubble
import cloud.molberg.hermesmobile.design.MetricRow
import cloud.molberg.hermesmobile.design.ModeChip
import cloud.molberg.hermesmobile.design.NoticeCard
import cloud.molberg.hermesmobile.design.PrimaryButton
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.TypingIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
@@ -78,8 +83,6 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.darkColorScheme
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -93,11 +96,8 @@ 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.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -112,7 +112,6 @@ import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.net.URLEncoder
import java.util.Locale
import java.util.concurrent.TimeUnit
enum class Screen(val label: String, val icon: ImageVector, val title: String) {
@@ -340,19 +339,10 @@ fun HermesNativeApp() {
runCatching { api.health() }.onSuccess { online = true }.onFailure { online = false }
}
MaterialTheme(
colorScheme = darkColorScheme(
background = AppBackground,
surface = RowSurface,
primary = Ink,
onPrimary = AppBackground,
onBackground = Ink,
onSurface = Ink
)
) {
HermesMobileTheme {
Scaffold(
modifier = Modifier.fillMaxSize(),
containerColor = AppBackground,
containerColor = HermesTheme.colors.background,
topBar = {
NativeTopBar(screen, online, onAction = {
if (screen == Screen.Inbox) {
@@ -428,15 +418,16 @@ 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, fontSize = 24.sp, fontWeight = FontWeight.SemiBold, color = Ink, lineHeight = 28.sp)
Text(screen.title, color = HermesTheme.colors.text, style = HermesTheme.typography.screenTitle)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Box(Modifier.size(8.dp).clip(CircleShape).background(if (online) Good else Muted))
Text(if (online) "connected" else "offline", color = if (online) Good else Muted, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
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)) {
Icon(Icons.Filled.Add, contentDescription = "New chat", tint = Ink)
Icon(Icons.Filled.Add, contentDescription = "New chat", tint = HermesTheme.colors.text)
}
}
}
@@ -444,14 +435,14 @@ fun NativeTopBar(screen: Screen, online: Boolean, onAction: () -> Unit) {
@Composable
fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) {
Surface(shadowElevation = 0.dp, color = AppBackground, tonalElevation = 0.dp) {
Surface(shadowElevation = HermesTheme.elevation.none, color = HermesTheme.colors.background, tonalElevation = HermesTheme.elevation.none) {
NavigationBar(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.height(76.dp),
containerColor = AppBackground,
tonalElevation = 0.dp
containerColor = HermesTheme.colors.background,
tonalElevation = HermesTheme.elevation.none
) {
Screen.entries.forEach { item ->
NavigationBarItem(
@@ -460,11 +451,11 @@ fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) {
icon = { Icon(item.icon, contentDescription = item.label) },
label = { Text(item.label, fontWeight = FontWeight.SemiBold, fontSize = 11.sp) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = Ink,
selectedTextColor = Ink,
selectedIconColor = HermesTheme.colors.text,
selectedTextColor = HermesTheme.colors.text,
indicatorColor = Color.Transparent,
unselectedIconColor = Muted,
unselectedTextColor = Muted
unselectedIconColor = HermesTheme.colors.textMuted,
unselectedTextColor = HermesTheme.colors.textMuted
)
)
}
@@ -572,11 +563,11 @@ fun ConversationThread(
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = Ink)
Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text)
}
Column(Modifier.weight(1f)) {
Text(conversation.title, color = Ink, fontSize = 18.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(if (conversation.busy) "Hermes is working..." else conversation.subtitle, color = Muted, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(conversation.title, color = HermesTheme.colors.text, style = HermesTheme.typography.sectionTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(if (conversation.busy) "Hermes is working..." else conversation.subtitle, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.label, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
LazyColumn(
@@ -588,7 +579,7 @@ fun ConversationThread(
item { EmptyState() }
}
items(conversation.messages) { message ->
Bubble(message)
MessageBubble(message.role, cleanMessage(message.content))
}
if (conversation.busy) {
item { TypingIndicator() }
@@ -612,7 +603,7 @@ fun InboxComposer(
canSend: Boolean,
onSend: () -> Unit
) {
Surface(color = AppBackground, tonalElevation = 0.dp) {
Surface(color = HermesTheme.colors.background, tonalElevation = HermesTheme.elevation.none) {
Row(
Modifier
.fillMaxWidth()
@@ -625,16 +616,16 @@ fun InboxComposer(
value = prompt,
onValueChange = onPrompt,
modifier = Modifier.weight(1f),
placeholder = { Text("Message Hermes", color = Muted) },
placeholder = { Text("Message Hermes", color = HermesTheme.colors.textMuted) },
minLines = 1,
maxLines = 5,
shape = RoundedCornerShape(24.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Surface,
unfocusedContainerColor = Surface,
focusedIndicatorColor = NeutralBorder,
unfocusedIndicatorColor = NeutralBorder,
cursorColor = Ink
focusedContainerColor = HermesTheme.colors.surfaceInput,
unfocusedContainerColor = HermesTheme.colors.surfaceInput,
focusedIndicatorColor = HermesTheme.colors.border,
unfocusedIndicatorColor = HermesTheme.colors.border,
cursorColor = HermesTheme.colors.text
)
)
IconButton(
@@ -643,12 +634,12 @@ fun InboxComposer(
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(if (!busy && canSend) Ink else Surface)
.background(if (!busy && canSend) HermesTheme.colors.primary else HermesTheme.colors.surface)
) {
Icon(
Icons.Filled.Send,
contentDescription = "Send",
tint = if (!busy && canSend) AppBackground else Muted
tint = if (!busy && canSend) HermesTheme.colors.onPrimary else HermesTheme.colors.textMuted
)
}
}
@@ -756,12 +747,12 @@ fun FilesScreen(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit)
SecondaryButton("Up", Icons.Filled.ArrowBack, Modifier.weight(0.8f), enabled = path != ".") { load(parentPath(path)) }
PrimaryButton(if (loading) "Loading..." else "Refresh", Icons.Filled.Refresh, Modifier.weight(1f), enabled = !loading) { load() }
}
Text(path, maxLines = 1, overflow = TextOverflow.Ellipsis, color = Muted, fontWeight = FontWeight.SemiBold)
Text(path, maxLines = 1, overflow = TextOverflow.Ellipsis, color = HermesTheme.colors.textMuted, fontWeight = FontWeight.SemiBold)
if (status.isNotBlank()) NoticeCard(status, danger = status.contains("failed", true) || status.contains("unable", true))
if (activePath.isNotBlank()) FlatCard {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
TinyLabel("Editing")
Text(activePath, color = Ink, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
Text(activePath, color = HermesTheme.colors.text, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis)
AppTextField(draft, { draft = it }, "File content", minLines = 10)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton("Close", Icons.Filled.Close, Modifier.weight(1f)) { activePath = ""; draft = "" }
@@ -781,7 +772,7 @@ fun FilesScreen(api: CompanionApi, notify: (String) -> Unit, onBack: () -> Unit)
}
SectionHeader("Directory", if (loading) "Loading entries..." else "${entries.size} item${if (entries.size == 1) "" else "s"}")
entries.forEach { entry ->
FileRow(entry) {
FileRow(entry.name, entry.type, entry.size) {
if (entry.type == "directory") {
load(entry.path)
} else {
@@ -865,9 +856,9 @@ fun AppearanceSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -
var completionDetail by remember { mutableStateOf(api.appearance.completionDetail) }
DetailList("Appearance", onBack) {
MetricRow("Theme", "Dark")
MetricRow("Background", "#212121")
MetricRow("Rows", "#303030")
MetricRow("Theme", "Follows system light/dark")
MetricRow("Tokens", "Color, spacing, type, shape, elevation, motion")
MetricRow("Status colors", "Success, warning, danger, neutral")
FieldLabel("Row density")
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
ModeChip("Comfortable", density == "Comfortable", Modifier.weight(1f)) { density = "Comfortable" }
@@ -882,7 +873,7 @@ fun AppearanceSettings(api: CompanionApi, notify: (String) -> Unit, onBack: () -
api.appearance = AppearancePrefs(density, completionDetail)
notify("Appearance settings saved locally.")
}
NoticeCard("These appearance preferences are stored on this Android device. The current native pass keeps the dark palette fixed to match the reference app.")
NoticeCard("These appearance preferences are stored on this Android device. The native theme now follows system light and dark mode.")
}
}
@@ -949,280 +940,3 @@ fun parentPath(path: String): String = path.split('/').filter { it.isNotBlank()
fun cleanMessage(text: String): String =
cleanServerText(text).take(12000).ifBlank { "Hermes finished without output." }
@Composable
fun ScreenList(content: @Composable ColumnScope.() -> Unit) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
content = { item { Column(verticalArrangement = Arrangement.spacedBy(14.dp), content = content) } }
)
}
@Composable
fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
ScreenList {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) { Icon(Icons.Filled.ArrowBack, contentDescription = "Back", tint = Ink) }
Text(title, color = Ink, fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
}
content()
}
}
@Composable
fun SectionHeader(title: String, detail: String) {
Column(Modifier.padding(top = 8.dp, start = 2.dp, end = 2.dp), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(title, color = Ink, fontSize = 18.sp, fontWeight = FontWeight.SemiBold)
Text(detail, color = Muted, fontSize = 13.sp, lineHeight = 18.sp)
}
}
@Composable
fun ListRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit) {
FlatCard {
Row(Modifier.clickable(onClick = onClick).padding(horizontal = 14.dp, vertical = 13.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Box(Modifier.size(32.dp).clip(RoundedCornerShape(8.dp)).background(Surface), contentAlignment = Alignment.Center) {
Icon(icon, null, tint = Ink, modifier = Modifier.size(19.dp))
}
Column(Modifier.weight(1f)) {
Text(title, color = Ink, fontWeight = FontWeight.SemiBold, fontSize = 16.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(subtitle, color = Muted, fontWeight = FontWeight.Normal, fontSize = 13.sp, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
Icon(Icons.Filled.ChevronRight, null, tint = Muted, modifier = Modifier.size(20.dp))
}
}
}
@Composable
fun FlatCard(modifier: Modifier = Modifier, color: Color = RowSurface, border: Color = Color.Transparent, content: @Composable () -> Unit) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = color),
border = BorderStroke(0.dp, border),
elevation = CardDefaults.cardElevation(0.dp)
) { content() }
}
@Composable
fun ModeChip(text: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
Surface(
modifier = modifier.height(44.dp).clip(RoundedCornerShape(12.dp)).clickable(onClick = onClick),
color = if (selected) Ink else RowSurface,
contentColor = if (selected) AppBackground else Ink
) {
Box(contentAlignment = Alignment.Center) {
Text(text, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
}
}
}
@Composable
fun EmptyState() {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(Modifier.size(48.dp).clip(CircleShape).background(Surface), contentAlignment = Alignment.Center) {
Icon(Icons.Filled.ChatBubble, null, tint = Ink)
}
Text("Message Hermes", color = Ink, fontWeight = FontWeight.SemiBold, fontSize = 20.sp)
Text("Ask a question or send a workspace task. Replies will appear here.", color = Muted, lineHeight = 19.sp)
}
}
@Composable
fun Bubble(msg: ChatMessage) {
val isUser = msg.role == "You"
val isError = msg.role == "Error"
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start
) {
Card(
modifier = Modifier.fillMaxWidth(0.86f),
shape = RoundedCornerShape(
topStart = 18.dp,
topEnd = 18.dp,
bottomStart = if (isUser) 18.dp else 4.dp,
bottomEnd = if (isUser) 4.dp else 18.dp
),
colors = CardDefaults.cardColors(containerColor = when {
isError -> DangerPanel
isUser -> UserBubble
else -> Surface
}),
elevation = CardDefaults.cardElevation(0.dp)
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
TinyLabel(if (isError) "Could not send" else msg.role)
SelectionContainer {
Text(
cleanMessage(msg.content),
color = if (isError) Danger else Ink,
fontWeight = FontWeight.Normal,
lineHeight = 19.sp
)
}
}
}
}
}
@Composable
fun TypingIndicator() {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Card(
modifier = Modifier.fillMaxWidth(0.68f),
shape = RoundedCornerShape(18.dp, 18.dp, 18.dp, 4.dp),
colors = CardDefaults.cardColors(containerColor = Surface),
elevation = CardDefaults.cardElevation(0.dp)
) {
Row(Modifier.padding(horizontal = 13.dp, vertical = 11.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.size(8.dp).clip(CircleShape).background(Muted))
Text("Hermes is working...", color = Muted, fontWeight = FontWeight.SemiBold, fontSize = 13.sp)
}
}
}
}
@Composable
fun NoticeCard(text: String, danger: Boolean = false) {
FlatCard(color = if (danger) DangerPanel else Surface) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Icon(if (danger) Icons.Filled.Info else Icons.Filled.CheckCircle, null, tint = if (danger) Danger else Good, modifier = Modifier.size(18.dp))
Text(text, Modifier.weight(1f), color = if (danger) Danger else Ink, fontWeight = FontWeight.Normal, lineHeight = 18.sp)
}
}
}
@Composable
fun CodeCard(text: String) {
FlatCard {
Text(text, Modifier.padding(14.dp), color = CodeText, fontFamily = FontFamily.Monospace, fontSize = 13.sp, lineHeight = 18.sp)
}
}
@Composable
fun FileRow(entry: FileEntry, onClick: () -> Unit) {
FlatCard {
Row(Modifier.clickable(onClick = onClick).padding(10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
Box(Modifier.size(38.dp).clip(RoundedCornerShape(8.dp)).background(Surface), contentAlignment = Alignment.Center) {
Icon(if (entry.type == "directory") Icons.Filled.Folder else Icons.Outlined.Article, null, tint = Ink, modifier = Modifier.size(20.dp))
}
Text(entry.name, Modifier.weight(1f), color = Ink, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(if (entry.type == "file") formatBytes(entry.size) else "dir", color = Muted, fontWeight = FontWeight.SemiBold, fontSize = 12.sp)
}
}
}
fun formatBytes(size: Long): String {
if (size < 1024) return "$size B"
val kb = size / 1024.0
if (kb < 1024) return String.format(Locale.US, "%.1f KB", kb)
return String.format(Locale.US, "%.1f MB", kb / 1024.0)
}
@Composable
fun MetricRow(label: String, value: String) {
FlatCard {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(label, color = Muted, fontWeight = FontWeight.SemiBold, fontSize = 13.sp, modifier = Modifier.width(104.dp))
Text(value, color = Ink, fontWeight = FontWeight.Normal, fontSize = 14.sp, modifier = Modifier.weight(1f), maxLines = 4, overflow = TextOverflow.Ellipsis)
}
}
}
@Composable
fun TinyLabel(text: String) {
Text(text.uppercase(Locale.US), color = Muted, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp)
}
@Composable
fun FieldLabel(text: String) {
Text(text.uppercase(Locale.US), color = Muted, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp)
}
@Composable
fun AppTextField(
value: String,
onValue: (String) -> Unit,
placeholder: String,
minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text,
password: Boolean = false
) {
OutlinedTextField(
value = value,
onValueChange = onValue,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(placeholder, color = Muted) },
minLines = minLines,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None,
shape = RoundedCornerShape(12.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = Surface,
unfocusedContainerColor = Surface,
focusedIndicatorColor = Muted,
unfocusedIndicatorColor = NeutralBorder,
cursorColor = Ink
)
)
}
@Composable
fun PrimaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.height(52.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Ink, contentColor = AppBackground, disabledContainerColor = Surface, disabledContentColor = Muted),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp, pressedElevation = 0.dp)
) {
Text(text, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.width(6.dp))
Icon(icon, null)
}
}
@Composable
fun SecondaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.height(52.dp),
shape = RoundedCornerShape(12.dp),
colors = ButtonDefaults.buttonColors(containerColor = Surface, contentColor = Ink, disabledContainerColor = Surface, disabledContentColor = Muted),
elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp, pressedElevation = 0.dp)
) {
Text(text, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.width(6.dp))
Icon(icon, null)
}
}
fun colorForStatus(status: String): Color = when (status) {
"done" -> Good
"running" -> Muted
"error" -> Danger
else -> Muted
}
val AppBackground = Color(0xFF212121)
val RowSurface = Color(0xFF303030)
val Surface = Color(0xFF2B2B2B)
val UserBubble = Color(0xFF263833)
val Ink = Color(0xFFF4F4F5)
val Muted = Color(0xFF9CA3AF)
val NeutralBorder = Color(0xFF3A3A3A)
val Good = Color(0xFF68D391)
val Danger = Color(0xFFF87171)
val DangerPanel = Color(0xFF3A2424)
val CodeText = Color(0xFFC7D2FE)
@@ -0,0 +1,388 @@
package cloud.molberg.hermesmobile.design
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.outlined.Article
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import java.util.Locale
@Composable
fun ScreenList(content: @Composable ColumnScope.() -> Unit) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = HermesTheme.spacing.lg, vertical = HermesTheme.spacing.sm),
verticalArrangement = Arrangement.spacedBy(14.dp),
content = { item { Column(verticalArrangement = Arrangement.spacedBy(14.dp), content = content) } }
)
}
@Composable
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)) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Back", tint = HermesTheme.colors.text)
}
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.panelTitle)
}
content()
}
}
@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(detail, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
}
}
@Composable
fun FlatCard(
modifier: Modifier = Modifier,
color: Color = HermesTheme.colors.surfaceRaised,
border: Color = Color.Transparent,
content: @Composable () -> Unit
) {
Card(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(HermesTheme.shapes.card),
colors = CardDefaults.cardColors(containerColor = color),
border = BorderStroke(HermesTheme.elevation.none, border),
elevation = CardDefaults.cardElevation(HermesTheme.elevation.none)
) { content() }
}
@Composable
fun ListRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit) {
FlatCard {
Row(
Modifier
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)
) {
Box(
Modifier
.size(32.dp)
.clip(RoundedCornerShape(HermesTheme.shapes.iconTile))
.background(HermesTheme.colors.surface),
contentAlignment = Alignment.Center
) {
Icon(icon, null, tint = HermesTheme.colors.text, modifier = Modifier.size(19.dp))
}
Column(Modifier.weight(1f)) {
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.rowTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(subtitle, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
Icon(Icons.Filled.ChevronRight, null, tint = HermesTheme.colors.textMuted, modifier = Modifier.size(20.dp))
}
}
}
@Composable
fun ModeChip(text: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
Surface(
modifier = modifier
.height(44.dp)
.clip(RoundedCornerShape(HermesTheme.shapes.chip))
.clickable(onClick = onClick),
color = if (selected) HermesTheme.colors.primary else HermesTheme.colors.surfaceRaised,
contentColor = if (selected) HermesTheme.colors.onPrimary else HermesTheme.colors.text
) {
Box(contentAlignment = Alignment.Center) {
Text(text, fontWeight = FontWeight.SemiBold, style = HermesTheme.typography.body)
}
}
}
@Composable
fun EmptyState() {
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 28.dp),
verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
Modifier
.size(48.dp)
.clip(CircleShape)
.background(HermesTheme.colors.surface),
contentAlignment = Alignment.Center
) {
Icon(Icons.Filled.ChatBubble, null, tint = HermesTheme.colors.text)
}
Text("Message Hermes", color = HermesTheme.colors.text, style = HermesTheme.typography.sectionTitle)
Text("Ask a question or send a workspace task. Replies will appear here.", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
}
}
@Composable
fun MessageBubble(role: String, content: String) {
val isUser = role == "You"
val isError = role == "Error"
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start
) {
Card(
modifier = Modifier.fillMaxWidth(0.86f),
shape = RoundedCornerShape(
topStart = HermesTheme.shapes.bubble,
topEnd = HermesTheme.shapes.bubble,
bottomStart = if (isUser) HermesTheme.shapes.bubble else 4.dp,
bottomEnd = if (isUser) 4.dp else HermesTheme.shapes.bubble
),
colors = CardDefaults.cardColors(
containerColor = when {
isError -> HermesTheme.status.dangerPanel
isUser -> HermesTheme.colors.userBubble
else -> HermesTheme.colors.surface
}
),
elevation = CardDefaults.cardElevation(HermesTheme.elevation.none)
) {
Column(Modifier.padding(HermesTheme.spacing.md), verticalArrangement = Arrangement.spacedBy(HermesTheme.spacing.xs)) {
TinyLabel(if (isError) "Could not send" else role)
SelectionContainer {
Text(
content,
color = if (isError) HermesTheme.status.danger else HermesTheme.colors.text,
style = HermesTheme.typography.body
)
}
}
}
}
}
@Composable
fun TypingIndicator() {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Start) {
Card(
modifier = Modifier.fillMaxWidth(0.68f),
shape = RoundedCornerShape(HermesTheme.shapes.bubble, HermesTheme.shapes.bubble, HermesTheme.shapes.bubble, 4.dp),
colors = CardDefaults.cardColors(containerColor = HermesTheme.colors.surface),
elevation = CardDefaults.cardElevation(HermesTheme.elevation.none)
) {
Row(
Modifier.padding(horizontal = 13.dp, vertical = 11.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm)
) {
Box(Modifier.size(8.dp).clip(CircleShape).background(HermesTheme.colors.textMuted))
Text("Hermes is working...", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, fontWeight = FontWeight.SemiBold)
}
}
}
}
@Composable
fun NoticeCard(text: String, danger: Boolean = false) {
FlatCard(color = if (danger) HermesTheme.status.dangerPanel else HermesTheme.colors.surface) {
Row(
Modifier.padding(HermesTheme.spacing.md),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Icon(
if (danger) Icons.Filled.Info else Icons.Filled.CheckCircle,
null,
tint = if (danger) HermesTheme.status.danger else HermesTheme.status.success,
modifier = Modifier.size(18.dp)
)
Text(text, Modifier.weight(1f), color = if (danger) HermesTheme.status.danger else HermesTheme.colors.text, style = HermesTheme.typography.body)
}
}
}
@Composable
fun CodeCard(text: String) {
FlatCard {
Text(
text,
Modifier
.horizontalScroll(rememberScrollState())
.padding(14.dp),
color = HermesTheme.colors.codeText,
style = HermesTheme.typography.code
)
}
}
@Composable
fun FileRow(name: String, type: String, size: Long, onClick: () -> Unit) {
FlatCard {
Row(
Modifier
.clickable(onClick = onClick)
.padding(10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
Modifier
.size(38.dp)
.clip(RoundedCornerShape(HermesTheme.shapes.iconTile))
.background(HermesTheme.colors.surface),
contentAlignment = Alignment.Center
) {
Icon(if (type == "directory") Icons.Filled.Folder else Icons.Outlined.Article, null, tint = HermesTheme.colors.text, modifier = Modifier.size(20.dp))
}
Text(name, Modifier.weight(1f), color = HermesTheme.colors.text, style = HermesTheme.typography.rowTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(if (type == "file") formatBytes(size) else "dir", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.label)
}
}
}
@Composable
fun MetricRow(label: String, value: String) {
FlatCard {
Row(
Modifier.padding(HermesTheme.spacing.md),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)
) {
Text(label, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, fontWeight = FontWeight.SemiBold, modifier = Modifier.width(104.dp))
Text(value, color = HermesTheme.colors.text, style = HermesTheme.typography.body, modifier = Modifier.weight(1f), maxLines = 4, overflow = TextOverflow.Ellipsis)
}
}
}
@Composable
fun TinyLabel(text: String) {
Text(text.uppercase(Locale.US), color = HermesTheme.colors.textMuted, style = HermesTheme.typography.tinyLabel)
}
@Composable
fun FieldLabel(text: String) {
Text(text.uppercase(Locale.US), color = HermesTheme.colors.textMuted, style = HermesTheme.typography.label)
}
@Composable
fun AppTextField(
value: String,
onValue: (String) -> Unit,
placeholder: String,
minLines: Int = 1,
keyboardType: KeyboardType = KeyboardType.Text,
password: Boolean = false
) {
OutlinedTextField(
value = value,
onValueChange = onValue,
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(placeholder, color = HermesTheme.colors.textMuted) },
minLines = minLines,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
visualTransformation = if (password) PasswordVisualTransformation() else VisualTransformation.None,
shape = RoundedCornerShape(HermesTheme.shapes.input),
colors = TextFieldDefaults.colors(
focusedContainerColor = HermesTheme.colors.surfaceInput,
unfocusedContainerColor = HermesTheme.colors.surfaceInput,
focusedIndicatorColor = HermesTheme.colors.textMuted,
unfocusedIndicatorColor = HermesTheme.colors.border,
cursorColor = HermesTheme.colors.text
)
)
}
@Composable
fun PrimaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.height(52.dp),
shape = RoundedCornerShape(HermesTheme.shapes.input),
colors = ButtonDefaults.buttonColors(
containerColor = HermesTheme.colors.primary,
contentColor = HermesTheme.colors.onPrimary,
disabledContainerColor = HermesTheme.colors.surface,
disabledContentColor = HermesTheme.colors.textMuted
),
elevation = ButtonDefaults.buttonElevation(defaultElevation = HermesTheme.elevation.none, pressedElevation = HermesTheme.elevation.none)
) {
Text(text, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.width(6.dp))
Icon(icon, null)
}
}
@Composable
fun SecondaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
Button(
onClick = onClick,
enabled = enabled,
modifier = modifier.height(52.dp),
shape = RoundedCornerShape(HermesTheme.shapes.input),
colors = ButtonDefaults.buttonColors(
containerColor = HermesTheme.colors.surface,
contentColor = HermesTheme.colors.text,
disabledContainerColor = HermesTheme.colors.surface,
disabledContentColor = HermesTheme.colors.textMuted
),
elevation = ButtonDefaults.buttonElevation(defaultElevation = HermesTheme.elevation.none, pressedElevation = HermesTheme.elevation.none)
) {
Text(text, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.width(6.dp))
Icon(icon, null)
}
}
fun formatBytes(size: Long): String {
if (size < 1024) return "$size B"
val kb = size / 1024.0
if (kb < 1024) return String.format(Locale.US, "%.1f KB", kb)
return String.format(Locale.US, "%.1f MB", kb / 1024.0)
}
@@ -0,0 +1,81 @@
package cloud.molberg.hermesmobile.design
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
@Composable
fun HermesMobileTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) darkHermesColors else lightHermesColors
val status = if (darkTheme) darkHermesStatusColors else lightHermesStatusColors
val materialColors = if (darkTheme) {
darkColorScheme(
background = colors.background,
surface = colors.surface,
surfaceVariant = colors.surfaceRaised,
primary = colors.primary,
onPrimary = colors.onPrimary,
onBackground = colors.text,
onSurface = colors.text,
outline = colors.border,
error = status.danger,
onError = colors.background
)
} else {
lightColorScheme(
background = colors.background,
surface = colors.surface,
surfaceVariant = colors.surfaceRaised,
primary = colors.primary,
onPrimary = colors.onPrimary,
onBackground = colors.text,
onSurface = colors.text,
outline = colors.border,
error = status.danger,
onError = colors.onPrimary
)
}
CompositionLocalProvider(
LocalHermesColors provides colors,
LocalHermesStatusColors provides status,
LocalHermesSpacing provides HermesSpacing(),
LocalHermesShapes provides HermesShapes(),
LocalHermesElevation provides HermesElevation(),
LocalHermesMotion provides HermesMotion(),
LocalHermesTypography provides HermesTypeScale()
) {
MaterialTheme(colorScheme = materialColors, content = content)
}
}
object HermesTheme {
val colors: HermesColors
@Composable get() = LocalHermesColors.current
val status: HermesStatusColors
@Composable get() = LocalHermesStatusColors.current
val spacing: HermesSpacing
@Composable get() = LocalHermesSpacing.current
val shapes: HermesShapes
@Composable get() = LocalHermesShapes.current
val elevation: HermesElevation
@Composable get() = LocalHermesElevation.current
val motion: HermesMotion
@Composable get() = LocalHermesMotion.current
val typography: HermesTypeScale
@Composable get() = LocalHermesTypography.current
}
@Composable
fun statusColorFor(status: String) = when (status.lowercase()) {
"done", "success", "ready", "connected" -> HermesTheme.status.success
"running", "loading", "pending" -> HermesTheme.status.neutral
"error", "failed", "unavailable", "offline" -> HermesTheme.status.danger
else -> HermesTheme.status.neutral
}
@@ -0,0 +1,148 @@
package cloud.molberg.hermesmobile.design
import androidx.compose.animation.core.FiniteAnimationSpec
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
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
@Immutable
data class HermesColors(
val background: Color,
val surface: Color,
val surfaceRaised: Color,
val surfaceInput: Color,
val userBubble: Color,
val primary: Color,
val onPrimary: Color,
val text: Color,
val textMuted: Color,
val border: Color,
val codeText: Color
)
@Immutable
data class HermesStatusColors(
val success: Color,
val successPanel: Color,
val warning: Color,
val warningPanel: Color,
val danger: Color,
val dangerPanel: Color,
val neutral: Color,
val neutralPanel: Color
)
@Immutable
data class HermesSpacing(
val none: Dp = 0.dp,
val xxs: Dp = 2.dp,
val xs: Dp = 4.dp,
val sm: Dp = 8.dp,
val md: Dp = 12.dp,
val lg: Dp = 16.dp,
val xl: Dp = 24.dp,
val xxl: Dp = 32.dp
)
@Immutable
data class HermesShapes(
val chip: Dp = 12.dp,
val card: Dp = 8.dp,
val input: Dp = 12.dp,
val bubble: Dp = 18.dp,
val iconTile: Dp = 8.dp
)
@Immutable
data class HermesElevation(
val none: Dp = 0.dp,
val raised: Dp = 1.dp
)
@Immutable
data class HermesMotion(
val screenTransitionMs: Int = 260,
val quickMs: Int = 140
) {
fun <T> screenTween(): FiniteAnimationSpec<T> = tween(screenTransitionMs)
fun <T> quickTween(): FiniteAnimationSpec<T> = tween(quickMs)
}
@Immutable
data class HermesTypeScale(
val screenTitle: TextStyle = TextStyle(fontSize = 24.sp, lineHeight = 28.sp, fontWeight = FontWeight.SemiBold),
val panelTitle: TextStyle = TextStyle(fontSize = 22.sp, lineHeight = 27.sp, fontWeight = FontWeight.SemiBold),
val sectionTitle: TextStyle = TextStyle(fontSize = 18.sp, lineHeight = 24.sp, fontWeight = FontWeight.SemiBold),
val rowTitle: TextStyle = TextStyle(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.SemiBold),
val body: TextStyle = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal),
val bodyStrong: TextStyle = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.SemiBold),
val caption: TextStyle = TextStyle(fontSize = 13.sp, lineHeight = 18.sp, fontWeight = FontWeight.Normal),
val label: TextStyle = TextStyle(fontSize = 12.sp, lineHeight = 16.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp),
val tinyLabel: TextStyle = TextStyle(fontSize = 11.sp, lineHeight = 14.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.sp),
val code: TextStyle = TextStyle(fontSize = 13.sp, lineHeight = 18.sp, fontFamily = FontFamily.Monospace)
)
val LocalHermesColors = staticCompositionLocalOf { darkHermesColors }
val LocalHermesStatusColors = staticCompositionLocalOf { darkHermesStatusColors }
val LocalHermesSpacing = staticCompositionLocalOf { HermesSpacing() }
val LocalHermesShapes = staticCompositionLocalOf { HermesShapes() }
val LocalHermesElevation = staticCompositionLocalOf { HermesElevation() }
val LocalHermesMotion = staticCompositionLocalOf { HermesMotion() }
val LocalHermesTypography = staticCompositionLocalOf { HermesTypeScale() }
val lightHermesColors = HermesColors(
background = Color(0xFFFAFAF8),
surface = Color(0xFFFFFFFF),
surfaceRaised = Color(0xFFF0F2F4),
surfaceInput = Color(0xFFFFFFFF),
userBubble = Color(0xFFE5F4ED),
primary = Color(0xFF17201C),
onPrimary = Color(0xFFFFFFFF),
text = Color(0xFF15191D),
textMuted = Color(0xFF59636E),
border = Color(0xFFD7DCE0),
codeText = Color(0xFF303F8F)
)
val darkHermesColors = HermesColors(
background = Color(0xFF16181A),
surface = Color(0xFF24272A),
surfaceRaised = Color(0xFF2F3337),
surfaceInput = Color(0xFF202326),
userBubble = Color(0xFF203B32),
primary = Color(0xFFF5F7F8),
onPrimary = Color(0xFF16181A),
text = Color(0xFFF4F6F7),
textMuted = Color(0xFFADB7C1),
border = Color(0xFF3E454C),
codeText = Color(0xFFC8D3FF)
)
val lightHermesStatusColors = HermesStatusColors(
success = Color(0xFF146C43),
successPanel = Color(0xFFE3F6EC),
warning = Color(0xFF875600),
warningPanel = Color(0xFFFFF3D6),
danger = Color(0xFFB42318),
dangerPanel = Color(0xFFFFE7E5),
neutral = Color(0xFF59636E),
neutralPanel = Color(0xFFF0F2F4)
)
val darkHermesStatusColors = HermesStatusColors(
success = Color(0xFF72D899),
successPanel = Color(0xFF183626),
warning = Color(0xFFF3C866),
warningPanel = Color(0xFF392B10),
danger = Color(0xFFFF8A80),
dangerPanel = Color(0xFF3A2222),
neutral = Color(0xFFADB7C1),
neutralPanel = Color(0xFF2F3337)
)