feat: extract compose design system
This commit is contained in:
+6
-1
@@ -5,10 +5,15 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Added a Compose design-system package with color, semantic status, spacing, typography, shape, elevation, and motion tokens.
|
||||
- Added light and dark Hermes Compose themes backed by Material 3 color schemes.
|
||||
- Added Compose previews for small phone, normal phone, tablet width, light theme, and dark theme component coverage.
|
||||
- Added native Android chat, workspace, settings, local defaults, companion health, and file editing surfaces for the beta client.
|
||||
- Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
|
||||
|
||||
### Changed
|
||||
- Extracted shared Compose cards, rows, buttons, text fields, notices, message bubbles, list scaffolds, and code surfaces out of `MainActivity.kt`.
|
||||
- Updated the native Appearance screen to describe system light/dark theming and design tokens.
|
||||
- Updated companion `/api/chat` to call `hermes chat -Q --source tool -q <prompt>`, sanitize CLI banners/help/control output, truncate large mobile replies, and return structured failures.
|
||||
- Reconciled `docs/ROADMAP.md` to the current native Android beta state.
|
||||
- Ignored `.gradle-user/` and `.dev/` alongside existing generated build artifacts.
|
||||
@@ -18,13 +23,13 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
|
||||
|
||||
### Verification
|
||||
- `git diff --check` — passed
|
||||
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android` — passed
|
||||
- `npm run typecheck --workspace @hermes-mobile/companion` — passed
|
||||
- `npm run lint --workspace @hermes-mobile/companion` — passed
|
||||
- `npm run build --workspace @hermes-mobile/companion` — passed
|
||||
- `npm run typecheck` — passed
|
||||
- `npm run lint` — passed
|
||||
- `npm run build` — passed
|
||||
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android` — blocked before compilation: network socket creation is not permitted while downloading Gradle 8.11.1
|
||||
|
||||
## Release history
|
||||
|
||||
|
||||
+7
-7
@@ -22,13 +22,6 @@
|
||||
|
||||
## Now — finish the foundation already in progress
|
||||
|
||||
- [ ] **F1 — Compose design system extraction**
|
||||
- Extract tokens for color, spacing, typography, shape, elevation, motion, and semantic status colors.
|
||||
- Implement light/dark themes with readable contrast; eliminate default-sample styling.
|
||||
- Split oversized UI into clear design, feature, and reusable component packages.
|
||||
- Add Compose previews: small phone, normal phone, tablet width, light, dark.
|
||||
- Verification: Android compile/build plus visual preview review.
|
||||
|
||||
- [ ] **F2 — Polished fake-data chat proving ground**
|
||||
- Session rail/list, conversation screen, context controls, keyboard-safe composer, send/cancel/retry behavior.
|
||||
- Render assistant/user/system/error/streaming messages from UI models.
|
||||
@@ -98,6 +91,13 @@ _Move finished items here with date, commit, and verification. Keep this section
|
||||
- Verification: `git diff --check` passed; `npm run typecheck --workspace @hermes-mobile/companion` passed; `npm run lint --workspace @hermes-mobile/companion` passed; `npm run build --workspace @hermes-mobile/companion` passed; `npm run typecheck` passed; `npm run lint` passed; `npm run build` passed.
|
||||
- Android debug build attempted with `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android`; blocked before compilation because network socket creation is not permitted while downloading Gradle 8.11.1.
|
||||
|
||||
- [x] **F1 — Compose design system extraction** — 2026-07-24, commit `PENDING` (`feat: extract compose design system`).
|
||||
- Extracted color, semantic status, spacing, typography, shape, elevation, and motion tokens.
|
||||
- Added light and dark Hermes Compose themes backed by Material 3 color schemes.
|
||||
- Split shared Compose cards, rows, controls, notices, message bubbles, list scaffolds, and code surfaces out of `MainActivity.kt`.
|
||||
- Added Compose previews for small phone, normal phone, tablet width, light theme, and dark theme component coverage.
|
||||
- Verification: `git diff --check` passed; `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android` passed.
|
||||
|
||||
## Work-loop checklist
|
||||
|
||||
Before coding:
|
||||
|
||||
@@ -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)
|
||||
|
||||
+388
@@ -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)
|
||||
)
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
## Current Milestone: Native Android Beta Chat and Connection UX
|
||||
|
||||
Status: in progress, close to beta validation. F0 source landing is complete; Android debug build validation remains environment-blocked.
|
||||
Status: in progress, close to beta validation. F0 source landing and F1 design-system extraction are complete; Android debug builds now pass in the shared Gradle cache environment.
|
||||
|
||||
Done:
|
||||
- Native Kotlin/Compose Android shell replaces the web-first mobile path for the beta surface.
|
||||
@@ -15,7 +15,7 @@ Done:
|
||||
- Companion and workspace TypeScript typecheck, build, and lint pass as of 2026-07-24.
|
||||
|
||||
Remaining for beta:
|
||||
- Validate Android debug build in an environment where Gradle can fetch or reuse the Gradle 8.11.1 distribution. On 2026-07-24, `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` failed before compilation because network socket creation is blocked while downloading `https://services.gradle.org/distributions/gradle-8.11.1-all.zip`.
|
||||
- Android debug build validation passed on 2026-07-24: `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug` from `apps/mobile/android`.
|
||||
- Exercise a real paired device against a running companion and authenticated Hermes CLI.
|
||||
- Exercise upstream-compatible transport/auth/session/streaming behavior end to end: bearer auth, health, chat request/response, session continuity expectations, streamed/final output handling, and failure recovery.
|
||||
- Add or run focused Android UI validation for chat/settings flows on small and large screens.
|
||||
|
||||
Reference in New Issue
Block a user