feat(theme): add Garnet preset with deep red accents (#1418)

This commit is contained in:
m57 authored and GitHub committed 2026-09-30 21:30:18 +03:00
1 parent 7b6aed4bcd
commit 26f259ddd8
8 files changed
+203 -5

No files matched your search

+1 -1
View File
@@ -41,7 +41,7 @@ The visual language follows Material 3 guidelines strictly adapted for developer
- **Core Principle**: Zero fluff. Fast render cycles, explicit states, flat surfaces, and zero visual ambiguity.
## Colors
The app supports 6 built-in presets (`Default/Slate`, `Monochrome`, `Gruvbox`, `Catppuccin`, `AMOLED`, `Nord`), plus optional Android 12+ wallpaper theming. `Theme.kt` selects the preset; `PaletteTemplate.kt` maps its colors to Material slots. AMOLED is dark-only; light mode falls back to Default.
The app supports 7 built-in presets (`Default/Slate`, `Monochrome`, `Gruvbox`, `Catppuccin`, `AMOLED`, `Nord`, `Garnet`), plus optional Android 12+ wallpaper theming. `Theme.kt` selects the preset; `PaletteTemplate.kt` maps its colors to Material slots. AMOLED is dark-only; light mode falls back to Default.
1. **Surfaces**:
- `MaterialTheme.colorScheme.background`: Main canvas and full-bleed chat background.
+1 -1
View File
@@ -67,7 +67,7 @@ If Hermes Mobile is useful to you, consider supporting its development on [Ko-fi
- **Gateway Status:** Monitor WebSocket connection, MCP servers, messaging channels, and OAuth providers.
- **Productivity:** View and manage tasks via integrated Kanban boards, track agent milestones, and browse session history.
- **Analytics & Billing:** Usage analytics dashboard and billing/subscription management.
- **Theming:** 6 built-in color presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord) plus Material You dynamic colors on supported devices.
- **Theming:** 7 built-in color presets (Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord, Garnet) plus Material You dynamic colors on supported devices.
- **Modern UX:** Native Material 3 design with pull-to-refresh, scroll-aware TopBar, and customizable bottom navigation.
---
@@ -25,7 +25,8 @@ app/src/main/java/com/m57/hermescontrol/theme/
├── GruvboxScheme.kt
├── CatppuccinScheme.kt
├── AmoledScheme.kt # dark-only (ThemeMode.DARK_ONLY)
└── NordScheme.kt
├── NordScheme.kt
└── GarnetScheme.kt
```
## The template — one shape for every theme
@@ -95,7 +96,7 @@ Enabled Material components also have a component-state contract:
`ThemeComponentContrastTest` checks the pinned Material role combinations in every
resolved preset/mode. `ThemeComponentGalleryTest` reads the installed Compose
public color defaults and verifies rendered pixels for all twelve preset/mode
public color defaults and verifies rendered pixels for all fourteen preset/mode
combinations, including focused/error fields. Disabled controls are deliberately
excluded from the enabled contrast contract; Material uses opacity to mute them.
Settings switches use Material defaults (`primary` track / `onPrimary` thumb),
@@ -105,6 +106,14 @@ Outlines must contrast against both surface and the unchecked switch track
(`surfaceContainerHighest`). Named palettes may need documented derived primary
or error tones for direct text; fixed roles still preserve their upstream accents.
## Garnet
Garnet preserves Default's slate surface ladder, typography, and semantic status
colors. Its seed is `#990000`, used as the light primary and dark primary
container. Dark primary/text/icons use a lighter `#FFB4AB` for contrast; rose
secondary and copper tertiary roles, their containers, fixed accents, inverse
primary, and surface tint follow the new accent family. Both modes are shipped.
## Theme modes
A theme declares which modes it ships via the factory you build it with:
@@ -17,7 +17,7 @@ import kotlinx.serialization.Serializable
enum class ThemePreference { SYSTEM, LIGHT, DARK }
@Serializable
enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD }
enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD, GARNET }
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.theme
import com.m57.hermescontrol.theme.presets.AmoledTheme
import com.m57.hermescontrol.theme.presets.CatppuccinTheme
import com.m57.hermescontrol.theme.presets.DefaultTheme
import com.m57.hermescontrol.theme.presets.GarnetTheme
import com.m57.hermescontrol.theme.presets.GruvboxTheme
import com.m57.hermescontrol.theme.presets.MonochromeTheme
import com.m57.hermescontrol.theme.presets.NordTheme
@@ -21,6 +22,7 @@ internal val ThemeRegistry =
ThemeDefinition(ThemePreset.CATPPUCCIN, CatppuccinTheme),
ThemeDefinition(ThemePreset.AMOLED, AmoledTheme),
ThemeDefinition(ThemePreset.NORD, NordTheme),
ThemeDefinition(ThemePreset.GARNET, GarnetTheme),
)
internal fun ThemePreset.palette(): ThemePalette = ThemeRegistry.single { it.preset == this }.palette
@@ -0,0 +1,185 @@
package com.m57.hermescontrol.theme.presets
import androidx.compose.ui.graphics.Color
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.PaletteColors
import com.m57.hermescontrol.theme.buildTheme
// ---------------------------------------------------------------------
// Default slate surfaces retained; accents use the Garnet red family.
// Garnet Dark — named swatches
// ---------------------------------------------------------------------
private val GarnetDarkBg0 = Color(0xFF0F1416)
private val GarnetDarkBg1 = Color(0xFF171C1E)
private val GarnetDarkBg2 = Color(0xFF1B2022)
private val GarnetDarkBg3 = Color(0xFF252B2D)
private val GarnetDarkBg4 = Color(0xFF303638)
private val GarnetDarkFg1 = Color(0xFFDEE3E5)
private val GarnetDarkFg3 = Color(0xFFBEC8CA)
private val GarnetGray = Color(0xFF889294) // same neutral gray in both modes
// "Bright" accents — high-visibility variants against dark surfaces.
private val GarnetBrightCopper = Color(0xFFEABD96)
private val GarnetBrightRose = Color(0xFFE5BDB8)
private val GarnetBrightGreen = Color(0xFF81D692)
private val GarnetBrightYellow = Color(0xFFECC248)
private val GarnetBrightRed = Color(0xFFFFB4AB)
private val GarnetBrightInfo = Color(0xFF8ECEFF)
// ---------------------------------------------------------------------
// Garnet Light — named swatches
// ---------------------------------------------------------------------
private val GarnetLightBg0 = Color(0xFFF5FAFC)
private val GarnetLightBg1 = Color(0xFFEFF4F6)
private val GarnetLightBg2 = Color(0xFFE9EEF0)
private val GarnetLightBg3 = Color(0xFFE3E8EA)
private val GarnetLightBg4 = Color(0xFFDEE3E5)
private val GarnetLightFg1 = Color(0xFF171C1E)
private val GarnetLightFg3 = Color(0xFF3F484A)
// "Faded" accents — muted variants for light background contrast.
private val GarnetAccent = Color(0xFF990000)
private val GarnetFadedCopper = Color(0xFF805531)
private val GarnetFadedRose = Color(0xFF775651)
private val GarnetFadedGreen = Color(0xFF126D2B)
private val GarnetFadedYellow = Color(0xFF725C00)
private val GarnetFadedRed = Color(0xFFBA1A1A)
private val GarnetFadedInfo = Color(0xFF006393)
// #990000 is the seed and light primary. Its contrast on the dark canvas is only
// 2.08:1; #FFB4AB supplies readable dark-mode text/icons (10.93:1).
private val GarnetLightRedContainer = Color(0xFFFFDAD5)
private val GarnetDarkRoseContainer = Color(0xFF573935)
private val GarnetDarkCopperContainer = Color(0xFF634025)
private val GarnetLightCopperContainer = Color(0xFFFFDCC1)
/** Garnet — Default slate surfaces with a #990000 red accent and matching warm tones. */
val GarnetTheme =
buildTheme(
dark =
PaletteColors(
primary = GarnetBrightRed,
onPrimary = GarnetDarkBg0,
primaryContainer = GarnetAccent,
onPrimaryContainer = GarnetLightBg0,
secondary = GarnetBrightRose,
onSecondary = GarnetDarkBg0,
secondaryContainer = GarnetDarkRoseContainer,
onSecondaryContainer = GarnetBrightRose,
tertiary = GarnetBrightCopper,
onTertiary = GarnetDarkBg0,
tertiaryContainer = GarnetDarkCopperContainer,
onTertiaryContainer = GarnetBrightCopper,
background = GarnetDarkBg0,
onBackground = GarnetDarkFg1,
surface = GarnetDarkBg0,
onSurface = GarnetDarkFg1,
surfaceVariant = GarnetDarkBg1,
onSurfaceVariant = GarnetDarkFg3,
surfaceDim = GarnetDarkBg0,
surfaceBright = GarnetDarkBg4,
primaryFixed = GarnetBrightRed,
primaryFixedDim = GarnetBrightRed,
onPrimaryFixed = GarnetDarkBg0,
onPrimaryFixedVariant = GarnetDarkBg0,
secondaryFixed = GarnetBrightRose,
secondaryFixedDim = GarnetBrightRose,
onSecondaryFixed = GarnetDarkBg0,
onSecondaryFixedVariant = GarnetDarkBg0,
tertiaryFixed = GarnetBrightCopper,
tertiaryFixedDim = GarnetBrightCopper,
onTertiaryFixed = GarnetDarkBg0,
onTertiaryFixedVariant = GarnetDarkBg0,
surfaceContainerLowest = GarnetDarkBg0,
surfaceContainerLow = GarnetDarkBg1,
surfaceContainer = GarnetDarkBg2,
surfaceContainerHigh = GarnetDarkBg3,
surfaceContainerHighest = GarnetDarkBg4,
inverseSurface = GarnetDarkFg1,
inverseOnSurface = GarnetDarkBg0,
inversePrimary = GarnetAccent,
outline = GarnetGray,
outlineVariant = GarnetDarkBg3,
scrim = GarnetDarkBg0,
status =
HermesStatusColors(
success = GarnetBrightGreen,
successContainer = GarnetDarkBg3,
onSuccess = GarnetDarkBg0,
warning = GarnetBrightYellow,
warningContainer = GarnetDarkBg3,
onWarning = GarnetDarkBg0,
error = GarnetBrightRed,
errorContainer = GarnetDarkBg3,
onError = GarnetDarkBg0,
onErrorContainer = GarnetBrightRed,
info = GarnetBrightInfo,
infoContainer = GarnetDarkBg3,
onInfo = GarnetDarkBg0,
),
),
light =
PaletteColors(
primary = GarnetAccent,
onPrimary = GarnetLightBg0,
primaryContainer = GarnetLightRedContainer,
onPrimaryContainer = GarnetAccent,
secondary = GarnetFadedRose,
onSecondary = GarnetLightBg0,
secondaryContainer = GarnetLightRedContainer,
onSecondaryContainer = GarnetFadedRose,
tertiary = GarnetFadedCopper,
onTertiary = GarnetLightBg0,
tertiaryContainer = GarnetLightCopperContainer,
onTertiaryContainer = GarnetFadedCopper,
background = GarnetLightBg0,
onBackground = GarnetLightFg1,
surface = GarnetLightBg0,
onSurface = GarnetLightFg1,
surfaceVariant = GarnetLightBg1,
onSurfaceVariant = GarnetLightFg3,
surfaceDim = GarnetLightBg4,
surfaceBright = GarnetLightBg0,
primaryFixed = GarnetBrightRed,
primaryFixedDim = GarnetBrightRed,
onPrimaryFixed = GarnetDarkBg0,
onPrimaryFixedVariant = GarnetDarkBg0,
secondaryFixed = GarnetBrightRose,
secondaryFixedDim = GarnetBrightRose,
onSecondaryFixed = GarnetDarkBg0,
onSecondaryFixedVariant = GarnetDarkBg0,
tertiaryFixed = GarnetBrightCopper,
tertiaryFixedDim = GarnetBrightCopper,
onTertiaryFixed = GarnetDarkBg0,
onTertiaryFixedVariant = GarnetDarkBg0,
surfaceContainerLowest = GarnetLightBg0,
surfaceContainerLow = GarnetLightBg1,
surfaceContainer = GarnetLightBg2,
surfaceContainerHigh = GarnetLightBg3,
surfaceContainerHighest = GarnetLightBg4,
inverseSurface = GarnetLightFg1,
inverseOnSurface = GarnetLightBg0,
inversePrimary = GarnetBrightRed,
outline = GarnetLightFg3,
outlineVariant = GarnetLightBg3,
scrim = GarnetLightBg0,
status =
HermesStatusColors(
success = GarnetFadedGreen,
successContainer = GarnetLightBg3,
onSuccess = GarnetLightBg0,
warning = GarnetFadedYellow,
warningContainer = GarnetLightBg3,
onWarning = GarnetLightBg0,
error = GarnetFadedRed,
errorContainer = GarnetLightBg3,
onError = GarnetLightBg0,
onErrorContainer = GarnetFadedRed,
info = GarnetFadedInfo,
infoContainer = GarnetLightBg3,
onInfo = GarnetLightBg0,
),
),
)
@@ -164,6 +164,7 @@ private fun ThemePreset.label(): String =
ThemePreset.CATPPUCCIN -> R.string.theme_preset_catppuccin
ThemePreset.AMOLED -> R.string.theme_preset_amoled
ThemePreset.NORD -> R.string.theme_preset_nord
ThemePreset.GARNET -> R.string.theme_preset_garnet
},
)
+1
View File
@@ -365,6 +365,7 @@
<string name="theme_preset_catppuccin">Catppuccin</string>
<string name="theme_preset_amoled">AMOLED Black (Dark only)</string>
<string name="theme_preset_nord">Nord</string>
<string name="theme_preset_garnet">Garnet</string>
<!-- Language -->
<string name="settings_item_language">Language</string>