feat(ui): modern theme, sectioned drawer, shared components (v1) (#1)
* feat(ui): modern theme, sectioned drawer, shared components (v1)
Foundation for the modern redesign — fixes the biggest UX problems
(that vague "better ui ux" ticket) without rewriting all 20 screens
at once. This puts the infrastructure in place and modernizes the
highest-traffic screens.
Theme (
- Disable Material You dynamic color by default — brand identity
(Voltage Purple) wins. Can opt back in via HermesControlTheme.
- 3-step elevation ladder (surface / surfaceVariant / surfaceHigh)
with subtle blue tint so dark mode stops reading as flat black.
- Full Material 3 type hierarchy — every style explicitly tuned,
tighter letter spacing, comfortable line heights.
- New Shapes.kt (xs→xl rounded corner tokens) and Spacing.kt
(xs/sm/md/lg/xl/xxl spacing tokens) — no more ad-hoc dp values.
Navigation (
- Sectioned drawer: 17 items were dumped at one indent level with
duplicate icons (Channels & Chat both used the Chat icon!). Now
grouped into 4 logical sections — Converse / Automate / Configure
/ Inspect — with section headers and sane per-item icons.
- Settings added to the drawer (was previously hidden behind
ChatScreen's gear icon — bad IA).
- gesturesEnabled check collapsed from an 18-term OR chain into a
single set-membership test.
Common components (
- HermesScaffold: shared drawer-aware Scaffold + TopAppBar with
slots for refresh / actions. Removes the per-screen duplication
seen in SkillsScreen, CronJobsScreen, ChannelsScreen, etc.
- StateViews: LoadingState / ErrorState / EmptyState / list content
padding tokens — every list screen now renders loading/error/
empty identically instead of ad-hoc per screen.
Modernized screens:
- ConnectScreen: hero brand badge (was a lone "⚡" glyph), pairing
as the primary path, manual token/host/port collapsed behind a
toggle (was always-visible clutter), Card-in-Card nesting removed.
- SkillsScreen: now uses HermesScaffold + EmptyState + ErrorState +
LoadingState. Adds a search field, leading icon per row, and a
category sublabel.
Follow-up commits will migrate the remaining ~15 list screens onto
HermesScaffold; this commit stops at the foundation + the two screens
that see the most daily use.
Verified: ktlint clean across the entire tree.
Co-Authored-By: Ronia <ronia@m57>
* fix(imports): add missing safeDrawingPadding + dp imports
CI caught three compile errors that ktlint doesn't flag (unresolved
references are not lint errors, they're compile errors):
- Navigation.kt:239 — Modifier.safeDrawingPadding() import was
dropped when the file was rewritten for the sectioned drawer.
- SkillsScreen.kt:117 — `16.dp` and `12.dp` references had no
`androidx.compose.ui.unit.dp` import after the file was rewritten
onto HermesScaffold.
No code changes — pure import fixes.
Co-Authored-By: Ronia <ronia@m57>
* ci: split monolithic job into 4 parallel jobs + summary
Before — one job ran ktlint → lint → test → build sequentially. If
ktlint failed you waited ~30s to find out, and the test/build stages
never even started (no useful artifacts uploaded on partial failure).
After — 4 jobs run in parallel on every push and PR:
1. ktlint (fast feedback, ~30s)
2. android-lint (parallel, includes compileDebugKotlin)
3. unit-tests (parallel, JUnit 5 + mockk)
4. build (gated by 1+2+3, only runs if all pass)
Plus a 5th `ci-summary` job that aggregates results so branch
protection only needs a single status check configured.
Other improvements:
- concurrency cancel-in-progress — new commits on the same ref
cancel the stale run instead of piling up.
- timeout-minutes on every job — no more hung runners burning minutes
on a stuck Gradle daemon.
- workflow_dispatch — can trigger manually from the Actions tab.
- Per-job artifact uploads with `if: always()` — reports upload even
when the job fails, so you can download test/lint reports to debug.
- Removed the monolithic "verification-reports" artifact that tried
to upload three unrelated paths and failed silently with "No files
were found" when an early step bailed.
Co-Authored-By: Ronia <ronia@m57>
* ci: silence ktlint artifact-not-found annotation
Adds `if-no-files-found: ignore` to the ktlint artifact upload step
because ktlint 1.2.1 does not produce HTML reports on success — only
on failure. The previous config `if-no-files-found: warn` (default)
emitted a noisy annotation on every green run.
Co-Authored-By: Ronia <ronia@m57>
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
Co-authored-by: Ronia <ronia@m57>
This commit is contained in:
11 files changed
+1013
-548
No files matched your search
@@ -2,42 +2,59 @@ package com.m57.hermescontrol.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
// Primary - Deep Purple
|
||||
val HermesPurple = Color(0xFF6B4EFF)
|
||||
val HermesPurpleLight = Color(0xFF9D8AFF)
|
||||
val HermesPurpleDark = Color(0xFF4A35B3)
|
||||
val HermesPurple80 = Color(0xFFCFC3FF)
|
||||
/**
|
||||
* Hermes Control brand palette — v2 (modern redesign).
|
||||
*
|
||||
* Design intent:
|
||||
* - Primary (voltage purple) is the single accent; surfaces stay calm.
|
||||
* - Secondary (plasma amber) is reserved for opt-in highlights only.
|
||||
* - Surfaces use a 3-step elevation ladder with a slight blue tint so dark
|
||||
* mode no longer reads as flat black.
|
||||
* - Semantic colors (success/warning/error/info) share the same saturation
|
||||
* band so they don't fight the brand.
|
||||
*/
|
||||
|
||||
// Secondary - Amber / Gold
|
||||
val HermesAmber = Color(0xFFFFB300)
|
||||
val HermesAmberLight = Color(0xFFFFE54C)
|
||||
val HermesPurple = Color(0xFF7C5CFF)
|
||||
val HermesPurpleLight = Color(0xFFAC93FF)
|
||||
val HermesPurpleDark = Color(0xFF5A3FE0)
|
||||
val HermesPurpleContainer = Color(0xFF2B2159)
|
||||
val HermesPurpleOnContainer = Color(0xFFD9CCFF)
|
||||
|
||||
val HermesAmber = Color(0xFFFFB627)
|
||||
val HermesAmberLight = Color(0xFFFFE082)
|
||||
val HermesAmberDark = Color(0xFFC68400)
|
||||
val HermesAmber80 = Color(0xFFFFE082)
|
||||
|
||||
// Dark theme surfaces
|
||||
val DarkBackground = Color(0xFF121212)
|
||||
val DarkSurface = Color(0xFF1E1E2E)
|
||||
val DarkSurfaceVariant = Color(0xFF2A2A3C)
|
||||
val DarkOnSurface = Color(0xFFE6E1E5)
|
||||
val DarkOnSurfaceVariant = Color(0xFFCAC4D0)
|
||||
val DarkBackground = Color(0xFF0B0B11)
|
||||
val DarkSurface = Color(0xFF121218)
|
||||
val DarkSurfaceVariant = Color(0xFF1C1C26)
|
||||
val DarkSurfaceHigh = Color(0xFF262633)
|
||||
val DarkOnSurface = Color(0xFFE8E6EE)
|
||||
val DarkOnSurfaceVariant = Color(0xFFB6B2C4)
|
||||
val DarkOutline = Color(0xFF4A4A5A)
|
||||
val DarkOutlineVariant = Color(0xFF2F2F3D)
|
||||
|
||||
// Light theme surfaces
|
||||
val LightBackground = Color(0xFFFFFBFE)
|
||||
val LightSurface = Color(0xFFFFFBFE)
|
||||
val LightSurfaceVariant = Color(0xFFE7E0EC)
|
||||
val LightOnSurface = Color(0xFF1C1B1F)
|
||||
val LightOnSurfaceVariant = Color(0xFF49454F)
|
||||
val LightBackground = Color(0xFFF7F6FB)
|
||||
val LightSurface = Color(0xFFFFFFFF)
|
||||
val LightSurfaceVariant = Color(0xFFEFEDF4)
|
||||
val LightSurfaceHigh = Color(0xFFE6E3EE)
|
||||
val LightOnSurface = Color(0xFF1A1A24)
|
||||
val LightOnSurfaceVariant = Color(0xFF56536A)
|
||||
val LightOutline = Color(0xFF79748E)
|
||||
val LightOutlineVariant = Color(0xFFC9C5D6)
|
||||
|
||||
// Status colors
|
||||
val StatusGreen = Color(0xFF4CAF50)
|
||||
val StatusRed = Color(0xFFEF5350)
|
||||
val StatusYellow = Color(0xFFFFC107)
|
||||
val StatusGreen = Color(0xFF3DDC84)
|
||||
val StatusGreenContainer = Color(0xFF143A23)
|
||||
val StatusRed = Color(0xFFFF5C5C)
|
||||
val StatusRedContainer = Color(0xFF3D1414)
|
||||
val StatusYellow = Color(0xFFFFB627)
|
||||
val StatusYellowContainer = Color(0xFF3D2F0F)
|
||||
val StatusBlue = Color(0xFF4DA8FF)
|
||||
val StatusBlueContainer = Color(0xFF0F2A3D)
|
||||
|
||||
// Chat bubble colors
|
||||
val UserBubble = Color(0xFF6B4EFF)
|
||||
val UserBubbleDark = Color(0xFF5A3FE0)
|
||||
val AssistantBubble = Color(0xFF2A2A3C)
|
||||
val AssistantBubbleLight = Color(0xFFE7E0EC)
|
||||
val SystemMessageColor = Color(0xFF9E9E9E)
|
||||
val ToolChipColor = Color(0xFF37474F)
|
||||
val ToolChipColorLight = Color(0xFFECEFF1)
|
||||
val UserBubble = HermesPurple
|
||||
val UserBubbleDark = HermesPurpleDark
|
||||
val AssistantBubble = Color(0xFF1C1C26)
|
||||
val AssistantBubbleLight = Color(0xFFEDEAF4)
|
||||
val SystemMessageColor = Color(0xFF8B879A)
|
||||
val ToolChipColor = Color(0xFF262633)
|
||||
val ToolChipColorLight = Color(0xFFE6E3EE)
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.m57.hermescontrol.theme
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Shapes
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Unified shape system for Hermes Control.
|
||||
*
|
||||
* - xs: chips, small badges
|
||||
* - sm: text fields, small cards
|
||||
* - md: standard cards, list rows
|
||||
* - lg: large cards, bottom sheets
|
||||
* - xl: feature cards, full-bleed surfaces
|
||||
*/
|
||||
val HermesShapes =
|
||||
Shapes(
|
||||
extraSmall = RoundedCornerShape(6.dp),
|
||||
small = RoundedCornerShape(10.dp),
|
||||
medium = RoundedCornerShape(16.dp),
|
||||
large = RoundedCornerShape(24.dp),
|
||||
extraLarge = RoundedCornerShape(32.dp),
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.m57.hermescontrol.theme
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Spacing tokens — use these instead of ad-hoc dp values so every screen
|
||||
* shares the same rhythm.
|
||||
*
|
||||
* Convention (Material 3 spacing scale, condensed):
|
||||
* xs = 4 hairline / chip gaps
|
||||
* sm = 8 default inner padding
|
||||
* md = 12 card content
|
||||
* lg = 16 screen edge, section gaps
|
||||
* xl = 24 hero spacing
|
||||
* xxl = 32 full-feature spacing
|
||||
*/
|
||||
object Spacing {
|
||||
val xs = 4.dp
|
||||
val sm = 8.dp
|
||||
val md = 12.dp
|
||||
val lg = 16.dp
|
||||
val xl = 24.dp
|
||||
val xxl = 32.dp
|
||||
}
|
||||
@@ -16,16 +16,22 @@ enum class ThemePreference { SYSTEM, LIGHT, DARK }
|
||||
|
||||
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
|
||||
|
||||
/**
|
||||
* Set to true to opt into Material You dynamic color (Android 12+).
|
||||
* Off by default — brand identity (Voltage Purple) is the priority.
|
||||
*/
|
||||
private const val ENABLE_DYNAMIC_COLOR = false
|
||||
|
||||
private val HermesDarkColorScheme =
|
||||
darkColorScheme(
|
||||
primary = HermesPurple,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = HermesPurpleDark,
|
||||
onPrimaryContainer = HermesPurple80,
|
||||
primaryContainer = HermesPurpleContainer,
|
||||
onPrimaryContainer = HermesPurpleOnContainer,
|
||||
secondary = HermesAmber,
|
||||
onSecondary = Color.Black,
|
||||
secondaryContainer = HermesAmberDark,
|
||||
onSecondaryContainer = HermesAmber80,
|
||||
secondaryContainer = Color(0xFF3D2F0F),
|
||||
onSecondaryContainer = HermesAmberLight,
|
||||
tertiary = HermesAmberLight,
|
||||
background = DarkBackground,
|
||||
onBackground = DarkOnSurface,
|
||||
@@ -33,21 +39,30 @@ private val HermesDarkColorScheme =
|
||||
onSurface = DarkOnSurface,
|
||||
surfaceVariant = DarkSurfaceVariant,
|
||||
onSurfaceVariant = DarkOnSurfaceVariant,
|
||||
error = Color(0xFFCF6679),
|
||||
onError = Color.Black,
|
||||
outline = Color(0xFF938F99),
|
||||
surfaceTint = HermesPurple,
|
||||
surfaceContainer = DarkSurfaceVariant,
|
||||
surfaceContainerHigh = DarkSurfaceHigh,
|
||||
surfaceContainerHighest = DarkSurfaceHigh,
|
||||
inverseSurface = Color(0xFFE8E6EE),
|
||||
inverseOnSurface = Color(0xFF1A1A24),
|
||||
error = StatusRed,
|
||||
onError = Color.White,
|
||||
errorContainer = StatusRedContainer,
|
||||
onErrorContainer = Color(0xFFFFB4B4),
|
||||
outline = DarkOutline,
|
||||
outlineVariant = DarkOutlineVariant,
|
||||
)
|
||||
|
||||
private val HermesLightColorScheme =
|
||||
lightColorScheme(
|
||||
primary = HermesPurple,
|
||||
primary = HermesPurpleDark,
|
||||
onPrimary = Color.White,
|
||||
primaryContainer = HermesPurple80,
|
||||
onPrimaryContainer = HermesPurpleDark,
|
||||
secondary = HermesAmber,
|
||||
onSecondary = Color.Black,
|
||||
secondaryContainer = HermesAmber80,
|
||||
onSecondaryContainer = HermesAmberDark,
|
||||
primaryContainer = HermesPurpleLight,
|
||||
onPrimaryContainer = Color(0xFF1E0F66),
|
||||
secondary = HermesAmberDark,
|
||||
onSecondary = Color.White,
|
||||
secondaryContainer = HermesAmberLight,
|
||||
onSecondaryContainer = Color(0xFF3D2F0F),
|
||||
tertiary = HermesAmberDark,
|
||||
background = LightBackground,
|
||||
onBackground = LightOnSurface,
|
||||
@@ -55,14 +70,24 @@ private val HermesLightColorScheme =
|
||||
onSurface = LightOnSurface,
|
||||
surfaceVariant = LightSurfaceVariant,
|
||||
onSurfaceVariant = LightOnSurfaceVariant,
|
||||
error = Color(0xFFB00020),
|
||||
surfaceTint = HermesPurple,
|
||||
surfaceContainer = LightSurfaceVariant,
|
||||
surfaceContainerHigh = LightSurfaceHigh,
|
||||
surfaceContainerHighest = LightSurfaceHigh,
|
||||
inverseSurface = Color(0xFF1A1A24),
|
||||
inverseOnSurface = Color(0xFFE8E6EE),
|
||||
error = Color(0xFFB3261E),
|
||||
onError = Color.White,
|
||||
outline = Color(0xFF79747E),
|
||||
errorContainer = Color(0xFFF9DEDC),
|
||||
onErrorContainer = Color(0xFF410E0B),
|
||||
outline = LightOutline,
|
||||
outlineVariant = LightOutlineVariant,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun HermesControlTheme(
|
||||
themePreference: ThemePreference = LocalThemePreference.current,
|
||||
enableDynamicColor: Boolean = ENABLE_DYNAMIC_COLOR,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val darkTheme =
|
||||
@@ -73,11 +98,11 @@ fun HermesControlTheme(
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val dynamicColor = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
val dynamicAvailable = enableDynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
val colorScheme =
|
||||
when {
|
||||
dynamicColor && darkTheme -> dynamicDarkColorScheme(context)
|
||||
dynamicColor && !darkTheme -> dynamicLightColorScheme(context)
|
||||
dynamicAvailable && darkTheme -> dynamicDarkColorScheme(context)
|
||||
dynamicAvailable && !darkTheme -> dynamicLightColorScheme(context)
|
||||
darkTheme -> HermesDarkColorScheme
|
||||
else -> HermesLightColorScheme
|
||||
}
|
||||
@@ -85,6 +110,7 @@ fun HermesControlTheme(
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = Typography,
|
||||
shapes = HermesShapes,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
@@ -6,31 +6,138 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
// Set of Material typography styles to start with
|
||||
/**
|
||||
* Full Material 3 type hierarchy — every style explicitly tuned so the
|
||||
* app stops inheriting Compose defaults.
|
||||
*
|
||||
* Letter spacing is tighter than M3 defaults (modern look), line heights are
|
||||
* comfortable for long-form.
|
||||
*/
|
||||
val Typography =
|
||||
Typography(
|
||||
// Display — hero numbers, big stats
|
||||
displayLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 44.sp,
|
||||
lineHeight = 52.sp,
|
||||
letterSpacing = (-0.5).sp,
|
||||
),
|
||||
displayMedium =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 44.sp,
|
||||
letterSpacing = (-0.25).sp,
|
||||
),
|
||||
displaySmall =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 30.sp,
|
||||
lineHeight = 38.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
// Headline — empty-state titles, screen intros
|
||||
headlineLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 28.sp,
|
||||
lineHeight = 34.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
headlineMedium =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 24.sp,
|
||||
lineHeight = 30.sp,
|
||||
letterSpacing = 0.sp,
|
||||
),
|
||||
headlineSmall =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
// Title — TopAppBar, card headings
|
||||
titleLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 20.sp,
|
||||
lineHeight = 26.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
titleMedium =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp,
|
||||
letterSpacing = 0.15.sp,
|
||||
),
|
||||
titleSmall =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
// Body — primary reading
|
||||
bodyLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 24.sp,
|
||||
letterSpacing = 0.25.sp,
|
||||
),
|
||||
bodyMedium =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.2.sp,
|
||||
),
|
||||
bodySmall =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.4.sp,
|
||||
),
|
||||
// Label — buttons, chips, captions
|
||||
labelLarge =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 20.sp,
|
||||
letterSpacing = 0.1.sp,
|
||||
),
|
||||
labelMedium =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
labelSmall =
|
||||
TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp,
|
||||
),
|
||||
/* Other default text styles to override
|
||||
titleLarge = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontSize = 22.sp,
|
||||
lineHeight = 28.sp,
|
||||
letterSpacing = 0.sp
|
||||
),
|
||||
labelSmall = TextStyle(
|
||||
fontFamily = FontFamily.Default,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 11.sp,
|
||||
lineHeight = 16.sp,
|
||||
letterSpacing = 0.5.sp
|
||||
)
|
||||
*/
|
||||
)
|
||||
Reference in new issue
Block a user