theme(849): unify presets behind one palette template + bespoke AMOLED status colors (#852)

* refactor(theme): unify all presets behind one palette template

* docs(theme): update THEMES.md and AGENTS.md for the palette template

* fix(theme): restore per-preset error slot contrast, merge StatusColors into HermesStatusColors

- onErrorContainer becomes an explicit per-preset field (was derived from
  onError — same-color pairs in Gruvbox/Catppuccin light made error text
  invisible; 6 screens render this slot)
- Default dark + AMOLED onError -> White (were <3:1 on their error color)
- drop StatusColors + statusColorsOf mapper; PaletteColors.status is typed
  HermesStatusColors directly (identical field lists, pure copy ceremony)
- ThemePalette getters now by lazy; schemeFor/statusFor accessors;
  requireNotNull on the default-theme fallback (DefaultTheme is FULL)
- resolvers internal for testability

* test(theme): contrast, mode-validation, and fallback contract tests

- every shipped mode's onError/error + onErrorContainer/errorContainer
  pair meets >= 3:1 WCAG contrast (kills the error-slot regression class)
- ThemeMode init validation rejects illegal palette combinations
- AMOLED darkOnly contract + light-mode fallback to Default light

* refactor(theme): Catppuccin — named swatches from the official palette spec

Hex values verified identical (zero color drift): repeated colors (Crust,
Surface0/1, Base, Text...) now live once as named swatches matching
catppuccin.com; bespoke light primary container tints stay local.
Mocha/Latte pair unchanged.

* refactor(theme): Gruvbox — authentic bright/faded accent scheme with named swatches

Redesign per gruvbox spec: bright accents on dark bg0-bg4, faded accents
on light bg0-bg4 (correct contrast play for light mode). Named swatches,
single Gray shared across modes. Dark error pair fixed for >= 3:1
(onErrorContainer -> DarkFg1 cream, was bright red on Bg3 = 1.87:1).

* feat(theme): replace default preset with Slate — modern slate blues + teal accents

The brand default (voltage purple/amber) becomes Slate: bright teal/blue
accents on dark bg0-bg4, faded variants on light bg0-bg4. Named swatches,
single neutral gray shared across modes. Slate is also the fallback for
darkOnly/lightOnly presets (AMOLED light mode).

* feat(theme): add Nord preset — Polar Night/Snow Storm surfaces, Frost/Aurora accents

Nord's single accent set is reused across both modes (pastel accents stay
light, so on* colors use Polar Night dark text in both modes — documented
in-file). Surface ladder shares outer tiers (Nord ships 4 dark + 3 light
tones for 5 tiers). Dark error pair fixed for >= 3:1 (onErrorContainer ->
Nord6, was Nord11 on Nord2 = 2.0:1). Wired into ThemePreset, the settings
picker, and docs; default label now reads Default (Slate).

* refactor(theme): Monochrome + AMOLED — pure grayscale ramp, true-black AMOLED variant

Monochrome: every swatch R=G=B, roles by lightness only (status colors
included — documented trade-off: pair with icons/labels). AMOLED: dark-only
true-black variant (pure #000000, OLED pixels off; #121212 baseline of
Monochrome doesn't do that), containers collapse where nothing darker
exists. Both ship maximum-contrast grayscale pairs (12.8:1+ on error
slots).

* chore(theme): drop Neon Noir, finalize docs + picker label

- remove NeonNoirScheme.kt + ThemePreset.NEON_NOIR everywhere (dispatcher,
  picker, strings EN/KO, tests, AGENTS.md, THEMES.md) — 6 presets remain:
  Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord
- picker label for the default preset is now plain 'Default'
- THEMES.md: preset conventions section (named swatches, bright/faded
  split, grayscale-status caveat, contrast test gate) + verify step runs
  testDebugUnitTest

* fix(theme): make drawer icon colors visibly follow the active preset

Selected icon now tints with the theme's primary accent (teal for Slate,
orange for Gruvbox, mauve for Catppuccin, frost-blue for Nord) instead of
onPrimaryContainer — the new presets' containers are all dark gray with
near-white text, so the selected row looked identical across themes.
Unselected icon/text colors pinned explicitly to onSurfaceVariant.

* chore(ui): remove lightning emoji from the app

- drawer title + About name: '⚡ Hermes' -> 'Hermes' (EN + KO)
- skill markers (chat self-improvement badge, memory graph): ⚡ -> 🛠
- in-progress indicators (subagent inspection): ⚡ -> 🔄

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-08 15:58:25 +03:00
1 parent 52e33a96b7
commit ab42e19b7f
21 files changed
+1312 -862

No files matched your search

+7 -3
View File
@@ -200,8 +200,12 @@ breakdown, edge cases, and timeline of previous occurrences.
### Theme
`Theme.kt` uses a preset-based theme system with 6 built-in presets: Default,
Monochrome, Gruvbox, Catppuccin, AMOLED, and Neon Noir. Each preset provides both
light and dark color schemes plus matching semantic status colors.
Monochrome, Gruvbox, Catppuccin, AMOLED, and Nord. Each preset provides light
and dark color schemes plus matching semantic status colors (AMOLED is
dark-only — light mode falls back to the default theme).
Every preset is a `ThemePalette` template fill (`PaletteTemplate.kt`): raw
`PaletteColors` + `HermesStatusColors` mapped into the Material 3
`ColorScheme` slots, with the error slots derived from the status set.
Dynamic color (Material You on API 31+) is controlled by a `useDynamicColors`
parameter on `HermesControlTheme`, not a compile-time constant. When dynamic
@@ -288,7 +292,7 @@ com.m57.hermescontrol/
│ └── ws/ HermesWsClient, JSON-RPC models, WsEvent, BillingRepository
├── notification/ ChatNotificationService, NotificationReplyReceiver
├── theme/ Color, Theme, Motion, Spacing, Shapes, Type, HermesStatusColors
│ └── presets/ Default, Monochrome, Gruvbox, Catppuccin, AMOLED, NeonNoir
│ └── presets/ Default, Monochrome, Gruvbox, Catppuccin, AMOLED, Nord
├── ui/
│ ├── common/ HermesScaffold, StateViews, SharedComponents, DetailDialog, DetailRows
│ └── 28 feature packages (achievements, analytics, authlogin, billing, channels,
@@ -260,7 +260,9 @@ fun MainNavigation(sessionId: String? = null) {
NavigationDrawerItemDefaults.colors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer,
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
),
)
}
@@ -2,67 +2,11 @@ package com.m57.hermescontrol.theme
import androidx.compose.ui.graphics.Color
// Hermes Mobile brand palette — v3 (Material You rewrite).
//
// On Android 12+ (API 31+) the app uses dynamic colour derived from the user's
// wallpaper — this palette is NOT used for primary/surface roles on those
// devices (see HermesControlTheme). On API 26–30 it serves as the full colour
// scheme. Semantic status colours and chat-bubble colours are ALWAYS
// brand-defined. All pairs verified for WCAG AA contrast (4.5:1 text, 3:1
// large text / icons).
// ── Brand: Voltage Purple (primary) ──────────────────────────────────────
val HermesPurple = Color(0xFF7C5CFF)
val HermesPurpleLight = Color(0xFFAC93FF)
val HermesPurpleDark = Color(0xFF5A3FE0)
val HermesPurpleContainer = Color(0xFF2B2159)
val HermesPurpleOnContainer = Color(0xFFD9CCFF)
// ── Brand: Plasma Amber (secondary) ─────────────────────────────────────
val HermesAmber = Color(0xFFFFB627)
val HermesAmberLight = Color(0xFFFFE082)
val HermesAmberDark = Color(0xFFC68400)
// ── Dark surfaces (5-step elevation ladder) ─────────────────────────────
val DarkBackground = Color(0xFF0B0B11)
val DarkSurface = Color(0xFF121218)
val DarkSurfaceVariant = Color(0xFF1C1C26)
val DarkSurfaceContainerLowest = Color(0xFF07070C)
val DarkSurfaceContainerLow = Color(0xFF0F0F14)
val DarkSurfaceContainer = Color(0xFF1C1C26)
val DarkSurfaceContainerHigh = Color(0xFF262633)
val DarkSurfaceContainerHighest = Color(0xFF30303D)
val DarkOnSurface = Color(0xFFE8E6EE)
val DarkOnSurfaceVariant = Color(0xFFB6B2C4)
val DarkOutline = Color(0xFF4A4A5A)
val DarkOutlineVariant = Color(0xFF2F2F3D)
val DarkInverseSurface = Color(0xFFE8E6EE)
val DarkInverseOnSurface = Color(0xFF1A1A24)
val DarkScrim = Color(0xFF000000)
// ── Light surfaces (5-step elevation ladder) ────────────────────────────
val LightBackground = Color(0xFFF7F6FB)
val LightSurface = Color(0xFFFFFFFF)
val LightSurfaceVariant = Color(0xFFEFEDF4)
val LightSurfaceContainerLowest = Color(0xFFFFFFFF)
val LightSurfaceContainerLow = Color(0xFFF3F1F8)
val LightSurfaceContainer = Color(0xFFEFEDF4)
val LightSurfaceContainerHigh = Color(0xFFE6E3EE)
val LightSurfaceContainerHighest = Color(0xFFDDD9EA)
val LightOnSurface = Color(0xFF1A1A24)
val LightOnSurfaceVariant = Color(0xFF56536A)
val LightOutline = Color(0xFF79748E)
val LightOutlineVariant = Color(0xFFC9C5D6)
val LightInverseSurface = Color(0xFF1A1A24)
val LightInverseOnSurface = Color(0xFFE8E6EE)
val LightScrim = Color(0xFF000000)
// ── Semantic status colours (always brand-defined) ──────────────────────
// Shared color tokens — only what code OUTSIDE the theme presets consumes.
// The preset palettes themselves are self-contained template fills in
// presets/ (see PaletteTemplate.kt); they don't import from here.
// Semantic status colors (always brand-defined).
val StatusGreen = Color(0xFF3DDC84)
val StatusGreenContainer = Color(0xFF143A23)
val StatusRed = Color(0xFFFF5C5C)
@@ -75,31 +19,25 @@ val StatusGrey = Color(0xFF9E9E9E)
val StatusGreyDark = Color(0xFF1A1A24)
val StatusGreyLight = Color(0xFFF5F5F5)
// ── Chat-specific colours (default theme fallback tokens) ─────────────────
val AssistantBubble = Color(0xFF1C1C26)
val AssistantBubbleLight = Color(0xFFEDEAF4)
val SystemMessageColor = Color(0xFF8B879A)
val ToolChipColor = Color(0xFF262633)
val ToolChipColorLight = Color(0xFFE6E3EE)
// ── Syntax highlighting tokens (code blocks) ─────────────────────────────
// VS Code Dark+ palette — good general readability across all presets.
// These are consumed exclusively by CodeBlockCard (MessageCards.kt).
// Chat-specific fallback tokens (default theme surface text colors).
val DarkOnSurface = Color(0xFFE8E6EE)
val LightOnSurface = Color(0xFF1A1A24)
// Syntax highlighting tokens (code blocks) — VS Code Dark+ palette — good
// general readability across all presets. Consumed by CodeBlockCard.
val CodeKeyword = Color(0xFF569CD6) // blue — for val, fun, class, etc.
val CodeString = Color(0xFFCE9178) // orange — for "string literals"
val CodeComment = Color(0xFF6A9955) // green — for // comments
val CodeNumber = Color(0xFFB5CEA8) // light green — for 42, 0xFF
val CodePunctuation = Color(0xFFD4D4D4) // gray — for {, (, ;, etc.
// ── Code block / terminal surface colours (issue #659) ───────────────
// Code block / terminal surface colors (issue #659).
val CodeTerminalBg = Color(0xFF1E1E1E)
val CodeTerminalBorder = Color(0xFF333333)
val CodeTerminalText = Color(0xFFD4D4D4)
val CodeTerminalMuted = Color(0xFF808080)
// ── Diff viewer tokens (issue #738) ──────────────────────────────────
// Diff viewer tokens (issue #738).
val CodeDiffAddBg = Color(0x263DDC84)
val CodeDiffAddText = Color(0xFF3DDC84)
val CodeDiffDeleteBg = Color(0x26FF5C5C)
@@ -107,9 +45,8 @@ val CodeDiffDeleteText = Color(0xFFFF5C5C)
val CodeDiffHunkBg = Color(0x264DA8FF)
val CodeDiffHunkText = Color(0xFF4DA8FF)
// ── Custom vector icons ─────────────────────────────────────────────
// Default fill for hand-declared ImageVector icons (e.g. NeurologyIcon).
// Mirrors Material's own icon set: vectors are drawn opaque black and
// Icon()'s tint (LocalContentColor) replaces the RGB at draw time — only
// the alpha channel survives, so this must stay opaque.
// Custom vector icons — default fill for hand-declared ImageVector icons
// (e.g. NeurologyIcon). Mirrors Material's own icon set: vectors are drawn
// opaque black and Icon()'s tint (LocalContentColor) replaces the RGB at
// draw time — only the alpha channel survives, so this must stay opaque.
val IconDefaultFill = Color.Black
@@ -24,6 +24,7 @@ data class HermesStatusColors(
val error: Color,
val errorContainer: Color,
val onError: Color,
val onErrorContainer: Color = StatusGreyLight,
val info: Color,
val infoContainer: Color,
val onInfo: Color,
@@ -0,0 +1,179 @@
package com.m57.hermescontrol.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
/**
* Theme template — the ONE shape every preset follows.
*
* A theme preset is a single [ThemePalette] built from raw [PaletteColors]
* specs. Every preset file has the same skeleton: fill in the colors, done.
* The mapping from raw colors to the Material 3 [ColorScheme] slots and the
* [HermesStatusColors] model lives here — no per-preset behavior.
*
* A theme can declare which modes it ships via [ThemeMode]:
* - [ThemeMode.FULL] — both dark and light palettes.
* - [ThemeMode.DARK_ONLY] — dark palette only; the dispatcher falls back to
* the default light palette in light mode (e.g. AMOLED — a light AMOLED
* makes no sense).
* - [ThemeMode.LIGHT_ONLY] — light palette only; symmetric fallback.
*/
enum class ThemeMode { FULL, DARK_ONLY, LIGHT_ONLY }
/**
* Raw color spec for ONE mode (dark or light) of a theme.
*
* The Material error slots are derived from [status] — the theme author
* defines semantic colors once and the scheme inherits them.
*/
data class PaletteColors(
val primary: Color,
val onPrimary: Color,
val primaryContainer: Color,
val onPrimaryContainer: Color,
val secondary: Color,
val onSecondary: Color,
val secondaryContainer: Color,
val onSecondaryContainer: Color,
val tertiary: Color,
val onTertiary: Color,
val tertiaryContainer: Color,
val onTertiaryContainer: Color,
val background: Color,
val onBackground: Color,
val surface: Color,
val onSurface: Color,
val surfaceVariant: Color,
val onSurfaceVariant: Color,
val surfaceContainerLowest: Color,
val surfaceContainerLow: Color,
val surfaceContainer: Color,
val surfaceContainerHigh: Color,
val surfaceContainerHighest: Color,
val inverseSurface: Color,
val inverseOnSurface: Color,
val inversePrimary: Color,
val outline: Color,
val outlineVariant: Color,
val scrim: Color,
val status: HermesStatusColors,
)
/** A complete theme — one per preset file, same shape everywhere. */
data class ThemePalette(
val mode: ThemeMode,
val dark: PaletteColors?,
val light: PaletteColors?,
) {
init {
require(mode != ThemeMode.FULL || (dark != null && light != null)) {
"FULL theme must provide both dark and light palettes"
}
require(mode != ThemeMode.DARK_ONLY || (dark != null && light == null)) {
"DARK_ONLY theme must provide a dark palette and no light palette"
}
require(mode != ThemeMode.LIGHT_ONLY || (dark == null && light != null)) {
"LIGHT_ONLY theme must provide a light palette and no dark palette"
}
}
val darkScheme: ColorScheme? by lazy { dark?.let(::darkSchemeOf) }
val lightScheme: ColorScheme? by lazy { light?.let(::lightSchemeOf) }
val darkStatus: HermesStatusColors? by lazy { dark?.status }
val lightStatus: HermesStatusColors? by lazy { light?.status }
fun schemeFor(dark: Boolean): ColorScheme? = if (dark) darkScheme else lightScheme
fun statusFor(dark: Boolean): HermesStatusColors? = if (dark) darkStatus else lightStatus
}
/** Full theme: bespoke dark + light palettes. */
fun buildTheme(
dark: PaletteColors,
light: PaletteColors,
): ThemePalette = ThemePalette(ThemeMode.FULL, dark, light)
/** Dark-only theme (e.g. AMOLED). Light mode falls back to the default theme. */
fun buildThemeDarkOnly(dark: PaletteColors): ThemePalette = ThemePalette(ThemeMode.DARK_ONLY, dark, null)
/** Light-only theme. Dark mode falls back to the default theme. */
fun buildThemeLightOnly(light: PaletteColors): ThemePalette = ThemePalette(ThemeMode.LIGHT_ONLY, null, light)
private fun darkSchemeOf(c: PaletteColors): ColorScheme =
darkColorScheme(
primary = c.primary,
onPrimary = c.onPrimary,
primaryContainer = c.primaryContainer,
onPrimaryContainer = c.onPrimaryContainer,
secondary = c.secondary,
onSecondary = c.onSecondary,
secondaryContainer = c.secondaryContainer,
onSecondaryContainer = c.onSecondaryContainer,
tertiary = c.tertiary,
onTertiary = c.onTertiary,
tertiaryContainer = c.tertiaryContainer,
onTertiaryContainer = c.onTertiaryContainer,
background = c.background,
onBackground = c.onBackground,
surface = c.surface,
onSurface = c.onSurface,
surfaceVariant = c.surfaceVariant,
onSurfaceVariant = c.onSurfaceVariant,
surfaceTint = c.primary,
surfaceContainerLowest = c.surfaceContainerLowest,
surfaceContainerLow = c.surfaceContainerLow,
surfaceContainer = c.surfaceContainer,
surfaceContainerHigh = c.surfaceContainerHigh,
surfaceContainerHighest = c.surfaceContainerHighest,
inverseSurface = c.inverseSurface,
inverseOnSurface = c.inverseOnSurface,
inversePrimary = c.inversePrimary,
error = c.status.error,
onError = c.status.onError,
errorContainer = c.status.errorContainer,
onErrorContainer = c.status.onErrorContainer,
outline = c.outline,
outlineVariant = c.outlineVariant,
scrim = c.scrim,
)
private fun lightSchemeOf(c: PaletteColors): ColorScheme =
lightColorScheme(
primary = c.primary,
onPrimary = c.onPrimary,
primaryContainer = c.primaryContainer,
onPrimaryContainer = c.onPrimaryContainer,
secondary = c.secondary,
onSecondary = c.onSecondary,
secondaryContainer = c.secondaryContainer,
onSecondaryContainer = c.onSecondaryContainer,
tertiary = c.tertiary,
onTertiary = c.onTertiary,
tertiaryContainer = c.tertiaryContainer,
onTertiaryContainer = c.onTertiaryContainer,
background = c.background,
onBackground = c.onBackground,
surface = c.surface,
onSurface = c.onSurface,
surfaceVariant = c.surfaceVariant,
onSurfaceVariant = c.onSurfaceVariant,
surfaceTint = c.primary,
surfaceContainerLowest = c.surfaceContainerLowest,
surfaceContainerLow = c.surfaceContainerLow,
surfaceContainer = c.surfaceContainer,
surfaceContainerHigh = c.surfaceContainerHigh,
surfaceContainerHighest = c.surfaceContainerHighest,
inverseSurface = c.inverseSurface,
inverseOnSurface = c.inverseOnSurface,
inversePrimary = c.inversePrimary,
error = c.status.error,
onError = c.status.onError,
errorContainer = c.status.errorContainer,
onErrorContainer = c.status.onErrorContainer,
outline = c.outline,
outlineVariant = c.outlineVariant,
scrim = c.scrim,
)
@@ -8,106 +8,116 @@ All theme code is under:
```
app/src/main/java/com/m57/hermescontrol/theme/
├── Theme.kt # dispatcher — resolves scheme + status colors per preset
├── Color.kt # brand design tokens (DarkSurface, HermesPurple, StatusGreen, …)
├── Theme.kt # dispatcher — preset lookup + mode fallback
├── PaletteTemplate.kt # the template — the ONE shape every theme follows
├── Color.kt # shared tokens for non-theme code (status fallbacks, code blocks, …)
├── HermesStatusColors.kt # semantic status color model (success/warning/error/info + on*)
├── Type.kt / Shapes.kt # typography + shape tokens
├── Spacing.kt / Motion.kt # spacing + motion tokens
└── presets/ # one file per ThemePreset
└── presets/ # one file per ThemePreset — same skeleton everywhere
├── DefaultScheme.kt
├── MonochromeScheme.kt
├── GruvboxScheme.kt
├── CatppuccinScheme.kt
├── AmoledScheme.kt
└── NeonNoirScheme.kt
├── AmoledScheme.kt # dark-only (ThemeMode.DARK_ONLY)
└── NordScheme.kt
```
## The 4-token shape
## The template — one shape for every theme
Every file in `presets/` is uniform — it exports the same four tokens:
| Token | Type | Purpose |
|-------|------|---------|
| `XxxDarkColorScheme` | `ColorScheme` | dark-mode Material 3 scheme |
| `XxxLightColorScheme` | `ColorScheme` | light-mode Material 3 scheme |
| `XxxDarkStatusColors` | `HermesStatusColors` | dark semantic status colors |
| `XxxLightStatusColors` | `HermesStatusColors` | light semantic status colors |
A preset with no bespoke status set (AMOLED) aliases the defaults:
Every preset file is a **single `ThemePalette`** built from raw color specs via
the template in `PaletteTemplate.kt`. A theme is a pure color spec: fill in
`PaletteColors` (the Material slots) + the semantic status set
(`HermesStatusColors`) and the app does the rest. No per-preset behavior, no
aliases, no shared-token imports.
```kotlin
val AmoledDarkStatusColors = DefaultDarkStatusColors
val AmoledLightStatusColors = DefaultLightStatusColors
val MyTheme =
buildTheme(
dark =
PaletteColors(
primary = Color(0xFF…),
// …every slot…
status =
HermesStatusColors(
success = Color(0xFF…),
// …
),
),
light = PaletteColors(/* … */),
)
```
AMOLED has no bespoke light scheme, so it aliases the default light scheme too:
The template maps `PaletteColors` into the Material 3 `ColorScheme` slots. The
Material `error` slots (`error`, `onError`, `errorContainer`, `onErrorContainer`)
are **derived** from the theme's status set — the theme author defines semantic
colors once.
```kotlin
val AmoledLightColorScheme = DefaultLightColorScheme
```
## Preset conventions
- **Named swatches** for any hex reused across slots (Gruvbox bg ladder,
Catppuccin's official names, Nord's nord0–nord15, Monochrome's mono ramp).
A theme with repeated hexes should define them once at the top of the file.
- **Bright/faded accent split** (Gruvbox, Slate): bright variants against dark
surfaces, muted "faded" variants against light surfaces so contrast holds
without neon saturation. Single-accent-set themes (Nord) instead keep dark
"on" text in both modes — pastel accents stay light, so Snow-Storm-style
light text would fail contrast.
- **Grayscale status colors** (Monochrome, AMOLED) separate success/warning/
error/info by lightness only — consumers must pair with icons or labels.
- Every shipped mode's error slot pairs are enforced at **>= 3:1 contrast** by
`ThemePaletteTest` — a preset that breaks this fails the unit-test gate.
## Theme modes
A theme declares which modes it ships via the factory you build it with:
| Factory | Mode | Meaning |
|---------|------|---------|
| `buildTheme(...)` | `FULL` | bespoke dark + light palettes |
| `buildThemeDarkOnly(...)` | `DARK_ONLY` | dark only; light mode falls back to the default theme (AMOLED) |
| `buildThemeLightOnly(...)` | `LIGHT_ONLY` | light only; dark mode falls back to the default theme |
The dispatcher falls back to the brand **default** theme for any mode a preset
doesn't ship — never to a sibling preset.
## The dispatcher
`Theme.kt` is a **pure lookup** — no special-casing. Given a `ThemePreset`
and a dark flag, it returns the matching scheme/status from the preset file:
`Theme.kt` is a **pure lookup** — no special-casing. Given a `ThemePreset` and
a dark flag, `themeFor()` returns the preset's `ThemePalette`, then the scheme /
status are read off it (with the mode fallback above):
```kotlin
private fun resolveColorScheme(preset: ThemePreset, darkTheme: Boolean) = when (preset) {
ThemePreset.DEFAULT -> if (darkTheme) DefaultDarkColorScheme else DefaultLightColorScheme
ThemePreset.MONOCHROME -> if (darkTheme) MonochromeDarkColorScheme else MonochromeLightColorScheme
// …one line per preset…
}
private fun resolveStatusColors(preset: ThemePreset, darkTheme: Boolean) = when (preset) {
ThemePreset.DEFAULT -> if (darkTheme) DefaultDarkStatusColors else DefaultLightStatusColors
private fun themeFor(preset: ThemePreset): ThemePalette = when (preset) {
ThemePreset.DEFAULT -> DefaultTheme
ThemePreset.MONOCHROME -> MonochromeTheme
// …one line per preset…
}
```
Dynamic (Material You) color on API 31+ overrides the preset scheme when
`useDynamicColors = true`.
## DEFAULT uses design tokens — that's intentional
`DefaultScheme.kt` imports ~47 named tokens from `Color.kt`
(`DarkBackground`, `HermesPurple`, `StatusGreenContainer`, …) and builds its
schemes from them. This is **by design**: DEFAULT is the brand reference theme,
so a palette change in `Color.kt` flows straight through.
The other presets are hand-authored community palettes and use **raw hex**
(`Color(0xFFFE8019)`), so they only import `Color` (+ `HermesStatusColors`).
Do **not** "fix" DEFAULT to raw hex to match them — that would break the token
linkage. The asymmetry is semantic, not sloppy.
`useDynamicColors = true`. Semantic status colors are always resolved from the
active preset via `LocalHermesStatusColors`.
## Adding a new theme
1. **Create** `presets/<Name>Scheme.kt` exporting the 4 tokens:
```kotlin
package com.m57.hermescontrol.theme.presets
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import com.m57.hermescontrol.theme.HermesStatusColors
val MyThemeDarkColorScheme = darkColorScheme(/* … */)
val MyThemeLightColorScheme = lightColorScheme(/* … */)
val MyThemeDarkStatusColors = HermesStatusColors(/* … */)
val MyThemeLightStatusColors = HermesStatusColors(/* … */)
```
2. **Add 2 lines** to each `when` in `Theme.kt` (`resolveColorScheme` +
`resolveStatusColors`), importing the new tokens at the top of the file.
1. **Create** `presets/<Name>Scheme.kt` as a template fill (see above) —
`buildTheme` for a full theme, `buildThemeDarkOnly` / `buildThemeLightOnly`
for single-mode themes. Every status color must be bespoke — no aliasing.
2. **Add 1 line** to `themeFor()` in `Theme.kt` + import the new val.
3. **Add the enum entry** `MY_THEME` to `ThemePreset` in `Theme.kt`.
4. **Wire the UI**: add the label + selection in
`ui/settings/components/AppearanceSection.kt` (and any string resource).
5. **Verify**:
```bash
./ktlint --format app/src/main/java/com/m57/hermescontrol/theme/presets/<Name>Scheme.kt
./gradlew compileDebugKotlin
./gradlew ktlintCheck testDebugUnitTest
```
`ThemePaletteTest` asserts >= 3:1 contrast on every shipped mode's error
slot pairs and the ThemeMode invariants — it must stay green.
## Conventions
- ktlint 1.2.1 is enforced in CI. Run `./ktlint --format` before committing.
- ktlint 1.2.1 is enforced in CI. Run `./gradlew ktlintCheck` before committing
(the gradle task is the gate, not the standalone `ktlint` binary).
- Import order is ASCII-lexicographic (uppercase before lowercase).
- Every change goes through a PR — never push directly to `main`.
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
@@ -9,75 +10,64 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalContext
import com.m57.hermescontrol.theme.presets.AmoledDarkColorScheme
import com.m57.hermescontrol.theme.presets.AmoledDarkStatusColors
import com.m57.hermescontrol.theme.presets.AmoledLightColorScheme
import com.m57.hermescontrol.theme.presets.AmoledLightStatusColors
import com.m57.hermescontrol.theme.presets.CatppuccinDarkColorScheme
import com.m57.hermescontrol.theme.presets.CatppuccinDarkStatusColors
import com.m57.hermescontrol.theme.presets.CatppuccinLightColorScheme
import com.m57.hermescontrol.theme.presets.CatppuccinLightStatusColors
import com.m57.hermescontrol.theme.presets.DefaultDarkColorScheme
import com.m57.hermescontrol.theme.presets.DefaultDarkStatusColors
import com.m57.hermescontrol.theme.presets.DefaultLightColorScheme
import com.m57.hermescontrol.theme.presets.DefaultLightStatusColors
import com.m57.hermescontrol.theme.presets.GruvboxDarkColorScheme
import com.m57.hermescontrol.theme.presets.GruvboxDarkStatusColors
import com.m57.hermescontrol.theme.presets.GruvboxLightColorScheme
import com.m57.hermescontrol.theme.presets.GruvboxLightStatusColors
import com.m57.hermescontrol.theme.presets.MonochromeDarkColorScheme
import com.m57.hermescontrol.theme.presets.MonochromeDarkStatusColors
import com.m57.hermescontrol.theme.presets.MonochromeLightColorScheme
import com.m57.hermescontrol.theme.presets.MonochromeLightStatusColors
import com.m57.hermescontrol.theme.presets.NeonNoirDarkColorScheme
import com.m57.hermescontrol.theme.presets.NeonNoirDarkStatusColors
import com.m57.hermescontrol.theme.presets.NeonNoirLightColorScheme
import com.m57.hermescontrol.theme.presets.NeonNoirLightStatusColors
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.GruvboxTheme
import com.m57.hermescontrol.theme.presets.MonochromeTheme
import com.m57.hermescontrol.theme.presets.NordTheme
import kotlinx.serialization.Serializable
@Serializable
enum class ThemePreference { SYSTEM, LIGHT, DARK }
@Serializable
enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NEON_NOIR }
enum class ThemePreset { DEFAULT, MONOCHROME, GRUVBOX, CATPPUCCIN, AMOLED, NORD }
val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
/**
* The 6 preset themes — one file each, all built from the same
* [PaletteTemplate] shape (see `PaletteTemplate.kt`).
*/
private fun themeFor(preset: ThemePreset): ThemePalette =
when (preset) {
ThemePreset.DEFAULT -> DefaultTheme
ThemePreset.MONOCHROME -> MonochromeTheme
ThemePreset.GRUVBOX -> GruvboxTheme
ThemePreset.CATPPUCCIN -> CatppuccinTheme
ThemePreset.AMOLED -> AmoledTheme
ThemePreset.NORD -> NordTheme
}
/**
* Resolve the Material 3 [ColorScheme] for a preset + dark flag.
*
* Every preset exports both a dark and light scheme. AMOLED's light scheme is
* an explicit alias of the brand default light scheme.
* A preset that doesn't ship the requested mode (DARK_ONLY / LIGHT_ONLY —
* e.g. AMOLED has no light palette) falls back to the default theme's
* palette for that mode.
*/
private fun resolveColorScheme(
internal fun resolveColorScheme(
preset: ThemePreset,
darkTheme: Boolean,
) = when (preset) {
ThemePreset.DEFAULT -> if (darkTheme) DefaultDarkColorScheme else DefaultLightColorScheme
ThemePreset.MONOCHROME -> if (darkTheme) MonochromeDarkColorScheme else MonochromeLightColorScheme
ThemePreset.GRUVBOX -> if (darkTheme) GruvboxDarkColorScheme else GruvboxLightColorScheme
ThemePreset.CATPPUCCIN -> if (darkTheme) CatppuccinDarkColorScheme else CatppuccinLightColorScheme
ThemePreset.AMOLED -> if (darkTheme) AmoledDarkColorScheme else AmoledLightColorScheme
ThemePreset.NEON_NOIR -> if (darkTheme) NeonNoirDarkColorScheme else NeonNoirLightColorScheme
): ColorScheme {
val theme = themeFor(preset)
// DefaultTheme is FULL — its scheme is never null for either mode.
return theme.schemeFor(darkTheme) ?: requireNotNull(DefaultTheme.schemeFor(darkTheme))
}
/**
* Resolve the semantic status colors for a preset + dark flag.
*
* Every preset exports both a dark and light status set. MONOCHROME and AMOLED
* alias the brand default status colors (they have no bespoke semantic set).
* Same fallback rule as the scheme.
*/
private fun resolveStatusColors(
internal fun resolveStatusColors(
preset: ThemePreset,
darkTheme: Boolean,
) = when (preset) {
ThemePreset.DEFAULT -> if (darkTheme) DefaultDarkStatusColors else DefaultLightStatusColors
ThemePreset.MONOCHROME -> if (darkTheme) MonochromeDarkStatusColors else MonochromeLightStatusColors
ThemePreset.GRUVBOX -> if (darkTheme) GruvboxDarkStatusColors else GruvboxLightStatusColors
ThemePreset.CATPPUCCIN -> if (darkTheme) CatppuccinDarkStatusColors else CatppuccinLightStatusColors
ThemePreset.AMOLED -> if (darkTheme) AmoledDarkStatusColors else AmoledLightStatusColors
ThemePreset.NEON_NOIR -> if (darkTheme) NeonNoirDarkStatusColors else NeonNoirLightStatusColors
): HermesStatusColors {
val theme = themeFor(preset)
// DefaultTheme is FULL — its status colors are never null for either mode.
return theme.statusFor(darkTheme) ?: requireNotNull(DefaultTheme.statusFor(darkTheme))
}
@Composable
@@ -1,63 +1,90 @@
package com.m57.hermescontrol.theme.presets
import androidx.compose.material3.darkColorScheme
import androidx.compose.ui.graphics.Color
import com.m57.hermescontrol.theme.HermesAmber
import com.m57.hermescontrol.theme.HermesAmberLight
import com.m57.hermescontrol.theme.HermesPurple
import com.m57.hermescontrol.theme.HermesPurpleOnContainer
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.PaletteColors
import com.m57.hermescontrol.theme.buildThemeDarkOnly
// ---------------------------------------------------------------------
// AMOLED — true-black variant of MonochromeTheme. Dark only:
// background is pure #000000 so OLED panels switch those pixels off,
// which MonochromeTheme's #121212 baseline doesn't give you.
// ---------------------------------------------------------------------
private val AmoledFloor = Color(0xFF000000) // background/surface/lowest — nothing darker exists
private val AmoledContainer = Color(0xFF0D0D0D) // surfaceContainer
private val AmoledContainerHigh = Color(0xFF1A1A1A) // surfaceContainerHigh, surfaceVariant, status containers
private val AmoledContainerHighest = Color(0xFF262626) // surfaceContainerHighest, primaryContainer
private val AmoledOutline = Color(0xFF808080)
private val AmoledOutlineVariant = Color(0xFF404040)
private val AmoledTextDim = Color(0xFFB3B3B3) // onSurfaceVariant
private val AmoledTextBright = Color(0xFFFFFFFF) // pure white — max contrast on black; primary, error, text
private val AmoledSecondary = Color(0xFFCCCCCC) // secondary accent + warning
private val AmoledTertiary = Color(0xFF999999) // tertiary accent + success
private val AmoledInfo = Color(0xFF808080) // info — same value as outline, distinct role
private val AmoledInversePrimary = Color(0xFF121212) // dark tone on the (light, fallback) inverseSurface
/**
* AMOLED color scheme (dark only).
* AMOLED theme — dark-only variant of MonochromeTheme for
* true-black displays. Background is pure #000000 rather than the
* #121212 baseline the standard Monochrome preset uses, so surfaceContainerLowest
* and surfaceContainerLow collapse to the same value — there's nothing
* darker than black to separate them into. Light mode isn't shipped
* (a light AMOLED theme is a contradiction); the dispatcher falls back
* to the default theme.
*
* AMOLED has no light variant — callers fall back to the brand default light
* scheme for light mode.
* Same accessibility note as MonochromeTheme: status colors are pure
* grayscale, separated only by lightness, so don't rely on them alone
* to convey success/warning/error/info — pair with an icon or label.
*/
val AmoledDarkColorScheme =
darkColorScheme(
primary = HermesPurple,
onPrimary = Color.White,
primaryContainer = Color(0xFF1A1040),
onPrimaryContainer = HermesPurpleOnContainer,
inversePrimary = Color(0xFF6750A4),
secondary = HermesAmber,
onSecondary = Color.Black,
secondaryContainer = Color(0xFF2A2000),
onSecondaryContainer = HermesAmberLight,
tertiary = HermesAmberLight,
onTertiary = Color.Black,
tertiaryContainer = Color(0xFF2A2000),
onTertiaryContainer = HermesAmberLight,
background = Color.Black,
onBackground = Color(0xFFE8E6EE),
surface = Color.Black,
onSurface = Color(0xFFE8E6EE),
surfaceVariant = Color(0xFF121218),
onSurfaceVariant = Color(0xFFB6B2C4),
surfaceTint = HermesPurple,
surfaceContainerLowest = Color.Black,
surfaceContainerLow = Color(0xFF08080A),
surfaceContainer = Color(0xFF0E0E12),
surfaceContainerHigh = Color(0xFF16161C),
surfaceContainerHighest = Color(0xFF1F1F28),
inverseSurface = Color(0xFFE8E6EE),
inverseOnSurface = Color.Black,
error = Color(0xFFCF6679),
onError = Color.Black,
errorContainer = Color(0xFF3700B3),
onErrorContainer = Color(0xFFF2B8B5),
outline = Color(0xFF3A3A4A),
outlineVariant = Color(0xFF252532),
scrim = Color.Black,
val AmoledTheme =
buildThemeDarkOnly(
dark =
PaletteColors(
primary = AmoledTextBright,
onPrimary = AmoledFloor,
primaryContainer = AmoledContainerHighest,
onPrimaryContainer = AmoledTextBright,
secondary = AmoledSecondary,
onSecondary = AmoledFloor,
secondaryContainer = AmoledContainerHigh,
onSecondaryContainer = AmoledSecondary,
tertiary = AmoledTertiary,
onTertiary = AmoledFloor,
tertiaryContainer = AmoledContainerHigh,
onTertiaryContainer = AmoledTertiary,
background = AmoledFloor,
onBackground = AmoledTextBright,
surface = AmoledFloor,
onSurface = AmoledTextBright,
surfaceVariant = AmoledContainerHigh,
onSurfaceVariant = AmoledTextDim,
surfaceContainerLowest = AmoledFloor,
surfaceContainerLow = AmoledFloor,
surfaceContainer = AmoledContainer,
surfaceContainerHigh = AmoledContainerHigh,
surfaceContainerHighest = AmoledContainerHighest,
inverseSurface = AmoledTextBright,
inverseOnSurface = AmoledFloor,
inversePrimary = AmoledInversePrimary,
outline = AmoledOutline,
outlineVariant = AmoledOutlineVariant,
scrim = Color.Black,
status =
HermesStatusColors(
success = AmoledTertiary,
successContainer = AmoledContainerHigh,
onSuccess = AmoledFloor,
warning = AmoledSecondary,
warningContainer = AmoledContainerHigh,
onWarning = AmoledFloor,
error = AmoledTextBright,
errorContainer = AmoledContainerHigh,
onError = AmoledFloor,
onErrorContainer = AmoledTextBright,
info = AmoledInfo,
infoContainer = AmoledContainerHigh,
onInfo = AmoledFloor,
),
),
)
/**
* AMOLED has no bespoke light scheme — reuse the brand default light scheme.
*/
val AmoledLightColorScheme = DefaultLightColorScheme
/** AMOLED status colors (dark) — reuses the brand default set. */
val AmoledDarkStatusColors = DefaultDarkStatusColors
/** AMOLED status colors (light) — reuses the brand default set. */
val AmoledLightStatusColors = DefaultLightStatusColors
@@ -1,118 +1,152 @@
package com.m57.hermescontrol.theme.presets
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.PaletteColors
import com.m57.hermescontrol.theme.buildTheme
/** Catppuccin color scheme (dark). */
val CatppuccinDarkColorScheme =
darkColorScheme(
primary = Color(0xFFCBA6F7), // Mauve
onPrimary = Color(0xFF11111B), // Crust
primaryContainer = Color(0xFF45475A), // Surface 1
onPrimaryContainer = Color(0xFFCDD6F4), // Text
inversePrimary = Color(0xFF8839EF), // Latte Mauve
secondary = Color(0xFF89B4FA), // Blue
onSecondary = Color(0xFF11111B), // Crust
secondaryContainer = Color(0xFF313244), // Surface 0
onSecondaryContainer = Color(0xFFBAC2DE), // Subtext 1
tertiary = Color(0xFFF5C2E7), // Pink
onTertiary = Color(0xFF11111B), // Crust
tertiaryContainer = Color(0xFF45475A), // Surface 1
onTertiaryContainer = Color(0xFFCDD6F4), // Text
background = Color(0xFF1E1E2E), // Base
onBackground = Color(0xFFCDD6F4), // Text
surface = Color(0xFF1E1E2E), // Base
onSurface = Color(0xFFCDD6F4), // Text
surfaceVariant = Color(0xFF313244), // Surface 0
onSurfaceVariant = Color(0xFFA6ADC8), // Subtext 0
surfaceTint = Color(0xFFCBA6F7), // Mauve
surfaceContainerLowest = Color(0xFF11111B), // Crust
surfaceContainerLow = Color(0xFF181825), // Mantle
surfaceContainer = Color(0xFF1E1E2E), // Base
surfaceContainerHigh = Color(0xFF313244), // Surface 0
surfaceContainerHighest = Color(0xFF45475A), // Surface 1
inverseSurface = Color(0xFFCDD6F4), // Text
inverseOnSurface = Color(0xFF1E1E2E), // Base
error = Color(0xFFF38BA8), // Red
onError = Color(0xFF11111B), // Crust
errorContainer = Color(0xFF313244), // Surface 0
onErrorContainer = Color(0xFFF38BA8), // Red
outline = Color(0xFF6C7086), // Overlay 0
outlineVariant = Color(0xFF45475A), // Surface 1
scrim = Color(0xFF11111B), // Crust
)
// ---------------------------------------------------------------------
// Mocha (dark) — named swatches, so repeated hexes (Crust, Surface0/1,
// Base, Text, Blue...) live in exactly one place.
// ---------------------------------------------------------------------
/** Catppuccin color scheme (light). */
val CatppuccinLightColorScheme =
lightColorScheme(
primary = Color(0xFF8839EF), // Mauve
onPrimary = Color(0xFFEFF1F5), // Base
primaryContainer = Color(0xFFE6CAFA), // Light Mauve Container
onPrimaryContainer = Color(0xFF380075),
inversePrimary = Color(0xFFCBA6F7), // Mocha Mauve
secondary = Color(0xFF1E66F5), // Blue
onSecondary = Color(0xFFEFF1F5), // Base
secondaryContainer = Color(0xFFE6E9EF), // Mantle
onSecondaryContainer = Color(0xFF4C4F69), // Text
tertiary = Color(0xFFEA76CB), // Pink
onTertiary = Color(0xFFEFF1F5), // Base
tertiaryContainer = Color(0xFFBCC0CC), // Surface 1
onTertiaryContainer = Color(0xFF4C4F69), // Text
background = Color(0xFFEFF1F5), // Base
onBackground = Color(0xFF4C4F69), // Text
surface = Color(0xFFEFF1F5), // Base
onSurface = Color(0xFF4C4F69), // Text
surfaceVariant = Color(0xFFCCD0DA), // Surface 0
onSurfaceVariant = Color(0xFF6C6F85), // Subtext 0
surfaceTint = Color(0xFF8839EF), // Mauve
surfaceContainerLowest = Color(0xFFDCE0E8), // Crust
surfaceContainerLow = Color(0xFFE6E9EF), // Mantle
surfaceContainer = Color(0xFFEFF1F5), // Base
surfaceContainerHigh = Color(0xFFCCD0DA), // Surface 0
surfaceContainerHighest = Color(0xFFBCC0CC), // Surface 1
inverseSurface = Color(0xFF4C4F69), // Text
inverseOnSurface = Color(0xFFEFF1F5), // Base
error = Color(0xFFD20F39), // Red
onError = Color(0xFFEFF1F5), // Base
errorContainer = Color(0xFFCCD0DA), // Surface 0
onErrorContainer = Color(0xFFD20F39), // Red
outline = Color(0xFF9CA0B0), // Overlay 0
outlineVariant = Color(0xFFBCC0CC), // Surface 1
scrim = Color(0xFFDCE0E8), // Crust
)
private val MochaMauve = Color(0xFFCBA6F7)
private val MochaBlue = Color(0xFF89B4FA)
private val MochaPink = Color(0xFFF5C2E7)
private val MochaGreen = Color(0xFFA6E3A1)
private val MochaYellow = Color(0xFFF9E2AF)
private val MochaRed = Color(0xFFF38BA8)
private val MochaText = Color(0xFFCDD6F4)
private val MochaSubtext1 = Color(0xFFBAC2DE)
private val MochaSubtext0 = Color(0xFFA6ADC8)
private val MochaOverlay0 = Color(0xFF6C7086)
private val MochaSurface1 = Color(0xFF45475A)
private val MochaSurface0 = Color(0xFF313244)
private val MochaBase = Color(0xFF1E1E2E)
private val MochaMantle = Color(0xFF181825)
private val MochaCrust = Color(0xFF11111B)
/** Catppuccin status colors (dark). */
val CatppuccinDarkStatusColors =
HermesStatusColors(
success = Color(0xFFA6E3A1), // Green
successContainer = Color(0xFF313244), // Surface 0
onSuccess = Color(0xFF11111B), // Crust
warning = Color(0xFFF9E2AF), // Yellow
warningContainer = Color(0xFF313244), // Surface 0
onWarning = Color(0xFF11111B), // Crust
error = Color(0xFFF38BA8), // Red
errorContainer = Color(0xFF313244), // Surface 0
onError = Color(0xFF11111B), // Crust
info = Color(0xFF89B4FA), // Blue
infoContainer = Color(0xFF313244), // Surface 0
onInfo = Color(0xFF11111B), // Crust
)
// ---------------------------------------------------------------------
// Latte (light) — named swatches
// ---------------------------------------------------------------------
/** Catppuccin status colors (light). */
val CatppuccinLightStatusColors =
HermesStatusColors(
success = Color(0xFF40A02B), // Green
successContainer = Color(0xFFCCD0DA), // Surface 0
onSuccess = Color(0xFFEFF1F5), // Base
warning = Color(0xFFDF8E1D), // Yellow
warningContainer = Color(0xFFCCD0DA), // Surface 0
onWarning = Color(0xFFEFF1F5), // Base
error = Color(0xFFD20F39), // Red
errorContainer = Color(0xFFCCD0DA), // Surface 0
onError = Color(0xFFEFF1F5), // Base
info = Color(0xFF1E66F5), // Blue
infoContainer = Color(0xFFCCD0DA), // Surface 0
onInfo = Color(0xFFEFF1F5), // Base
private val LatteMauve = Color(0xFF8839EF)
private val LatteBlue = Color(0xFF1E66F5)
private val LattePink = Color(0xFFEA76CB)
private val LatteGreen = Color(0xFF40A02B)
private val LatteYellow = Color(0xFFDF8E1D)
private val LatteRed = Color(0xFFD20F39)
private val LatteText = Color(0xFF4C4F69)
private val LatteSubtext0 = Color(0xFF6C6F85)
private val LatteOverlay0 = Color(0xFF9CA0B0)
private val LatteSurface1 = Color(0xFFBCC0CC)
private val LatteSurface0 = Color(0xFFCCD0DA)
private val LatteBase = Color(0xFFEFF1F5)
private val LatteMantle = Color(0xFFE6E9EF)
private val LatteCrust = Color(0xFFDCE0E8)
// Bespoke tint used only for the light primary container — not part of
// the official Catppuccin swatch set, kept local to this file.
private val LattePrimaryContainer = Color(0xFFE6CAFA)
private val LatteOnPrimaryContainer = Color(0xFF380075)
/** Catppuccin theme — Mocha (dark) / Latte (light). */
val CatppuccinTheme =
buildTheme(
dark =
PaletteColors(
primary = MochaMauve,
onPrimary = MochaCrust,
primaryContainer = MochaSurface1,
onPrimaryContainer = MochaText,
secondary = MochaBlue,
onSecondary = MochaCrust,
secondaryContainer = MochaSurface0,
onSecondaryContainer = MochaSubtext1,
tertiary = MochaPink,
onTertiary = MochaCrust,
tertiaryContainer = MochaSurface1,
onTertiaryContainer = MochaText,
background = MochaBase,
onBackground = MochaText,
surface = MochaBase,
onSurface = MochaText,
surfaceVariant = MochaSurface0,
onSurfaceVariant = MochaSubtext0,
surfaceContainerLowest = MochaCrust,
surfaceContainerLow = MochaMantle,
surfaceContainer = MochaBase,
surfaceContainerHigh = MochaSurface0,
surfaceContainerHighest = MochaSurface1,
inverseSurface = MochaText,
inverseOnSurface = MochaBase,
inversePrimary = LatteMauve,
outline = MochaOverlay0,
outlineVariant = MochaSurface1,
scrim = MochaCrust,
status =
HermesStatusColors(
success = MochaGreen,
successContainer = MochaSurface0,
onSuccess = MochaCrust,
warning = MochaYellow,
warningContainer = MochaSurface0,
onWarning = MochaCrust,
error = MochaRed,
errorContainer = MochaSurface0,
onError = MochaCrust,
onErrorContainer = MochaRed,
info = MochaBlue,
infoContainer = MochaSurface0,
onInfo = MochaCrust,
),
),
light =
PaletteColors(
primary = LatteMauve,
onPrimary = LatteBase,
primaryContainer = LattePrimaryContainer,
onPrimaryContainer = LatteOnPrimaryContainer,
secondary = LatteBlue,
onSecondary = LatteBase,
secondaryContainer = LatteMantle,
onSecondaryContainer = LatteText,
tertiary = LattePink,
onTertiary = LatteBase,
tertiaryContainer = LatteSurface1,
onTertiaryContainer = LatteText,
background = LatteBase,
onBackground = LatteText,
surface = LatteBase,
onSurface = LatteText,
surfaceVariant = LatteSurface0,
onSurfaceVariant = LatteSubtext0,
surfaceContainerLowest = LatteCrust,
surfaceContainerLow = LatteMantle,
surfaceContainer = LatteBase,
surfaceContainerHigh = LatteSurface0,
surfaceContainerHighest = LatteSurface1,
inverseSurface = LatteText,
inverseOnSurface = LatteBase,
inversePrimary = MochaMauve,
outline = LatteOverlay0,
outlineVariant = LatteSurface1,
scrim = LatteCrust,
status =
HermesStatusColors(
success = LatteGreen,
successContainer = LatteSurface0,
onSuccess = LatteBase,
warning = LatteYellow,
warningContainer = LatteSurface0,
onWarning = LatteBase,
error = LatteRed,
errorContainer = LatteSurface0,
onError = LatteBase,
onErrorContainer = LatteRed,
info = LatteBlue,
infoContainer = LatteSurface0,
onInfo = LatteBase,
),
),
)
@@ -1,165 +1,152 @@
package com.m57.hermescontrol.theme.presets
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import com.m57.hermescontrol.theme.DarkBackground
import com.m57.hermescontrol.theme.DarkInverseOnSurface
import com.m57.hermescontrol.theme.DarkInverseSurface
import com.m57.hermescontrol.theme.DarkOnSurface
import com.m57.hermescontrol.theme.DarkOnSurfaceVariant
import com.m57.hermescontrol.theme.DarkOutline
import com.m57.hermescontrol.theme.DarkOutlineVariant
import com.m57.hermescontrol.theme.DarkScrim
import com.m57.hermescontrol.theme.DarkSurface
import com.m57.hermescontrol.theme.DarkSurfaceContainer
import com.m57.hermescontrol.theme.DarkSurfaceContainerHigh
import com.m57.hermescontrol.theme.DarkSurfaceContainerHighest
import com.m57.hermescontrol.theme.DarkSurfaceContainerLow
import com.m57.hermescontrol.theme.DarkSurfaceContainerLowest
import com.m57.hermescontrol.theme.DarkSurfaceVariant
import com.m57.hermescontrol.theme.HermesAmber
import com.m57.hermescontrol.theme.HermesAmberDark
import com.m57.hermescontrol.theme.HermesAmberLight
import com.m57.hermescontrol.theme.HermesPurple
import com.m57.hermescontrol.theme.HermesPurpleContainer
import com.m57.hermescontrol.theme.HermesPurpleDark
import com.m57.hermescontrol.theme.HermesPurpleLight
import com.m57.hermescontrol.theme.HermesPurpleOnContainer
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LightBackground
import com.m57.hermescontrol.theme.LightInverseOnSurface
import com.m57.hermescontrol.theme.LightInverseSurface
import com.m57.hermescontrol.theme.LightOnSurface
import com.m57.hermescontrol.theme.LightOnSurfaceVariant
import com.m57.hermescontrol.theme.LightOutline
import com.m57.hermescontrol.theme.LightOutlineVariant
import com.m57.hermescontrol.theme.LightScrim
import com.m57.hermescontrol.theme.LightSurface
import com.m57.hermescontrol.theme.LightSurfaceContainer
import com.m57.hermescontrol.theme.LightSurfaceContainerHigh
import com.m57.hermescontrol.theme.LightSurfaceContainerHighest
import com.m57.hermescontrol.theme.LightSurfaceContainerLow
import com.m57.hermescontrol.theme.LightSurfaceContainerLowest
import com.m57.hermescontrol.theme.LightSurfaceVariant
import com.m57.hermescontrol.theme.StatusBlue
import com.m57.hermescontrol.theme.StatusBlueContainer
import com.m57.hermescontrol.theme.StatusGreen
import com.m57.hermescontrol.theme.StatusGreenContainer
import com.m57.hermescontrol.theme.StatusGreyLight
import com.m57.hermescontrol.theme.StatusRed
import com.m57.hermescontrol.theme.StatusRedContainer
import com.m57.hermescontrol.theme.StatusYellow
import com.m57.hermescontrol.theme.StatusYellowContainer
import com.m57.hermescontrol.theme.PaletteColors
import com.m57.hermescontrol.theme.buildTheme
/** Brand default color scheme (dark) — Voltage Purple primary. */
val DefaultDarkColorScheme =
darkColorScheme(
primary = HermesPurple,
onPrimary = Color.White,
primaryContainer = HermesPurpleContainer,
onPrimaryContainer = HermesPurpleOnContainer,
inversePrimary = HermesPurpleDark,
secondary = HermesAmber,
onSecondary = Color.Black,
secondaryContainer = Color(0xFF3D2F0F),
onSecondaryContainer = HermesAmberLight,
tertiary = HermesAmberLight,
onTertiary = Color.Black,
tertiaryContainer = Color(0xFF3D2F0F),
onTertiaryContainer = HermesAmberLight,
background = DarkBackground,
onBackground = DarkOnSurface,
surface = DarkSurface,
onSurface = DarkOnSurface,
surfaceVariant = DarkSurfaceVariant,
onSurfaceVariant = DarkOnSurfaceVariant,
surfaceTint = HermesPurple,
surfaceContainerLowest = DarkSurfaceContainerLowest,
surfaceContainerLow = DarkSurfaceContainerLow,
surfaceContainer = DarkSurfaceContainer,
surfaceContainerHigh = DarkSurfaceContainerHigh,
surfaceContainerHighest = DarkSurfaceContainerHighest,
inverseSurface = DarkInverseSurface,
inverseOnSurface = DarkInverseOnSurface,
error = StatusRed,
onError = Color.White,
errorContainer = StatusRedContainer,
onErrorContainer = Color(0xFFFFB4B4),
outline = DarkOutline,
outlineVariant = DarkOutlineVariant,
scrim = DarkScrim,
)
// ---------------------------------------------------------------------
// Slate Dark — named swatches
// ---------------------------------------------------------------------
/** Brand default color scheme (light) — Voltage Purple primary. */
val DefaultLightColorScheme =
lightColorScheme(
primary = HermesPurpleDark,
onPrimary = Color.White,
primaryContainer = HermesPurpleLight,
onPrimaryContainer = Color(0xFF1E0F66),
inversePrimary = HermesPurpleLight,
secondary = HermesAmberDark,
onSecondary = Color.White,
secondaryContainer = HermesAmberLight,
onSecondaryContainer = Color(0xFF3D2F0F),
tertiary = HermesAmberDark,
onTertiary = Color.White,
tertiaryContainer = HermesAmberLight,
onTertiaryContainer = Color(0xFF3D2F0F),
background = LightBackground,
onBackground = LightOnSurface,
surface = LightSurface,
onSurface = LightOnSurface,
surfaceVariant = LightSurfaceVariant,
onSurfaceVariant = LightOnSurfaceVariant,
surfaceTint = HermesPurple,
surfaceContainerLowest = LightSurfaceContainerLowest,
surfaceContainerLow = LightSurfaceContainerLow,
surfaceContainer = LightSurfaceContainer,
surfaceContainerHigh = LightSurfaceContainerHigh,
surfaceContainerHighest = LightSurfaceContainerHighest,
inverseSurface = LightInverseSurface,
inverseOnSurface = LightInverseOnSurface,
error = Color(0xFFB3261E),
onError = Color.White,
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF410E0B),
outline = LightOutline,
outlineVariant = LightOutlineVariant,
scrim = LightScrim,
)
private val SlateDarkBg0 = Color(0xFF0F1416)
private val SlateDarkBg1 = Color(0xFF171C1E)
private val SlateDarkBg2 = Color(0xFF1B2022)
private val SlateDarkBg3 = Color(0xFF252B2D)
private val SlateDarkBg4 = Color(0xFF303638)
private val SlateDarkFg1 = Color(0xFFDEE3E5)
private val SlateDarkFg2 = Color(0xFFCEE7EC)
private val SlateDarkFg3 = Color(0xFFBEC8CA)
private val SlateGray = Color(0xFF889294) // same neutral gray in both modes
/** Default status colors (dark). */
val DefaultDarkStatusColors =
HermesStatusColors(
success = StatusGreen,
successContainer = StatusGreenContainer,
onSuccess = StatusGreyLight,
warning = StatusYellow,
warningContainer = StatusYellowContainer,
onWarning = StatusGreyLight,
error = StatusRed,
errorContainer = StatusRedContainer,
onError = StatusGreyLight,
info = StatusBlue,
infoContainer = StatusBlueContainer,
onInfo = StatusGreyLight,
)
// "Bright" accents — high-visibility variants against dark surfaces.
private val SlateBrightTeal = Color(0xFF80D5D2)
private val SlateBrightBlue = Color(0xFFA3C9EC)
private val SlateBrightCyan = Color(0xFFB2CBD0)
private val SlateBrightGreen = Color(0xFF81D692)
private val SlateBrightYellow = Color(0xFFECC248)
private val SlateBrightRed = Color(0xFFFFB4AB)
private val SlateBrightInfo = Color(0xFF8ECEFF)
/** Default status colors (light). */
val DefaultLightStatusColors =
HermesStatusColors(
success = Color(0xFF1B873A),
successContainer = Color(0xFFD7F5E0),
onSuccess = Color(0xFFFDF8FF),
warning = HermesAmberDark,
warningContainer = Color(0xFFFFEAB3),
onWarning = Color(0xFFFDF8FF),
error = Color(0xFFB3261E),
errorContainer = Color(0xFFF9DEDC),
onError = Color(0xFFFDF8FF),
info = Color(0xFF2E6FBD),
infoContainer = Color(0xFFD4E7FF),
onInfo = Color(0xFFFDF8FF),
// ---------------------------------------------------------------------
// Slate Light — named swatches
// ---------------------------------------------------------------------
private val SlateLightBg0 = Color(0xFFF5FAFC)
private val SlateLightBg1 = Color(0xFFEFF4F6)
private val SlateLightBg2 = Color(0xFFE9EEF0)
private val SlateLightBg3 = Color(0xFFE3E8EA)
private val SlateLightBg4 = Color(0xFFDEE3E5)
private val SlateLightFg1 = Color(0xFF171C1E)
private val SlateLightFg2 = Color(0xFF051F23)
private val SlateLightFg3 = Color(0xFF3F484A)
// "Faded" accents — muted variants for light background contrast.
private val SlateFadedTeal = Color(0xFF006A68)
private val SlateFadedBlue = Color(0xFF3B6184)
private val SlateFadedCyan = Color(0xFF4A6367)
private val SlateFadedGreen = Color(0xFF126D2B)
private val SlateFadedYellow = Color(0xFF725C00)
private val SlateFadedRed = Color(0xFFBA1A1A)
private val SlateFadedInfo = Color(0xFF006393)
/** Default theme (Slate) — modern slate blues with teal accent notes. */
val DefaultTheme =
buildTheme(
dark =
PaletteColors(
primary = SlateBrightTeal,
onPrimary = SlateDarkBg0,
primaryContainer = SlateDarkBg4,
onPrimaryContainer = SlateDarkFg1,
secondary = SlateBrightCyan,
onSecondary = SlateDarkBg0,
secondaryContainer = SlateDarkBg3,
onSecondaryContainer = SlateDarkFg2,
tertiary = SlateBrightBlue,
onTertiary = SlateDarkBg0,
tertiaryContainer = SlateDarkBg4,
onTertiaryContainer = SlateDarkFg1,
background = SlateDarkBg0,
onBackground = SlateDarkFg1,
surface = SlateDarkBg0,
onSurface = SlateDarkFg1,
surfaceVariant = SlateDarkBg1,
onSurfaceVariant = SlateDarkFg3,
surfaceContainerLowest = SlateDarkBg0,
surfaceContainerLow = SlateDarkBg1,
surfaceContainer = SlateDarkBg2,
surfaceContainerHigh = SlateDarkBg3,
surfaceContainerHighest = SlateDarkBg4,
inverseSurface = SlateDarkFg1,
inverseOnSurface = SlateDarkBg0,
inversePrimary = SlateFadedTeal,
outline = SlateGray,
outlineVariant = SlateDarkBg3,
scrim = SlateDarkBg0,
status =
HermesStatusColors(
success = SlateBrightGreen,
successContainer = SlateDarkBg3,
onSuccess = SlateDarkBg0,
warning = SlateBrightYellow,
warningContainer = SlateDarkBg3,
onWarning = SlateDarkBg0,
error = SlateBrightRed,
errorContainer = SlateDarkBg3,
onError = SlateDarkBg0,
onErrorContainer = SlateBrightRed,
info = SlateBrightInfo,
infoContainer = SlateDarkBg3,
onInfo = SlateDarkBg0,
),
),
light =
PaletteColors(
primary = SlateFadedTeal,
onPrimary = SlateLightBg0,
primaryContainer = SlateLightBg4,
onPrimaryContainer = SlateLightFg1,
secondary = SlateFadedCyan,
onSecondary = SlateLightBg0,
secondaryContainer = SlateLightBg3,
onSecondaryContainer = SlateLightFg2,
tertiary = SlateFadedBlue,
onTertiary = SlateLightBg0,
tertiaryContainer = SlateLightBg4,
onTertiaryContainer = SlateLightFg1,
background = SlateLightBg0,
onBackground = SlateLightFg1,
surface = SlateLightBg0,
onSurface = SlateLightFg1,
surfaceVariant = SlateLightBg1,
onSurfaceVariant = SlateLightFg3,
surfaceContainerLowest = SlateLightBg0,
surfaceContainerLow = SlateLightBg1,
surfaceContainer = SlateLightBg2,
surfaceContainerHigh = SlateLightBg3,
surfaceContainerHighest = SlateLightBg4,
inverseSurface = SlateLightFg1,
inverseOnSurface = SlateLightBg0,
inversePrimary = SlateBrightTeal,
outline = SlateGray,
outlineVariant = SlateLightBg3,
scrim = SlateLightBg0,
status =
HermesStatusColors(
success = SlateFadedGreen,
successContainer = SlateLightBg3,
onSuccess = SlateLightBg0,
warning = SlateFadedYellow,
warningContainer = SlateLightBg3,
onWarning = SlateLightBg0,
error = SlateFadedRed,
errorContainer = SlateLightBg3,
onError = SlateLightBg0,
onErrorContainer = SlateFadedRed,
info = SlateFadedInfo,
infoContainer = SlateLightBg3,
onInfo = SlateLightBg0,
),
),
)
@@ -1,118 +1,151 @@
package com.m57.hermescontrol.theme.presets
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.PaletteColors
import com.m57.hermescontrol.theme.buildTheme
/** Gruvbox color scheme (dark). */
val GruvboxDarkColorScheme =
darkColorScheme(
primary = Color(0xFFFE8019),
onPrimary = Color(0xFF282828),
primaryContainer = Color(0xFFD65D0E),
onPrimaryContainer = Color(0xFFFBF1C7),
inversePrimary = Color(0xFFD65D0E),
secondary = Color(0xFFFABD2F),
onSecondary = Color(0xFF282828),
secondaryContainer = Color(0xFF3C3836),
onSecondaryContainer = Color(0xFFBDAE93),
tertiary = Color(0xFF8EC07C),
onTertiary = Color(0xFF282828),
tertiaryContainer = Color(0xFF458588),
onTertiaryContainer = Color(0xFFFBF1C7),
background = Color(0xFF282828),
onBackground = Color(0xFFFBF1C7),
surface = Color(0xFF32302F),
onSurface = Color(0xFFFBF1C7),
surfaceVariant = Color(0xFF3C3836),
onSurfaceVariant = Color(0xFFBDAE93),
surfaceTint = Color(0xFFFE8019),
surfaceContainerLowest = Color(0xFF1D2021),
surfaceContainerLow = Color(0xFF282828),
surfaceContainer = Color(0xFF32302F),
surfaceContainerHigh = Color(0xFF3C3836),
surfaceContainerHighest = Color(0xFF504945),
inverseSurface = Color(0xFFFBF1C7),
inverseOnSurface = Color(0xFF282828),
error = Color(0xFFFB4934),
onError = Color(0xFF282828),
errorContainer = Color(0xFFCC241D),
onErrorContainer = Color(0xFFFBF1C7),
outline = Color(0xFF665C54),
outlineVariant = Color(0xFF504945),
scrim = Color(0xFF1D2021),
)
// ---------------------------------------------------------------------
// Gruvbox Dark (medium contrast) — named swatches
// ---------------------------------------------------------------------
/** Gruvbox color scheme (light). */
val GruvboxLightColorScheme =
lightColorScheme(
primary = Color(0xFFD65D0E),
onPrimary = Color(0xFFFBF1C7),
primaryContainer = Color(0xFFFE8019),
onPrimaryContainer = Color(0xFF282828),
inversePrimary = Color(0xFFFE8019),
secondary = Color(0xFF458588),
onSecondary = Color(0xFFFBF1C7),
secondaryContainer = Color(0xFFEBDBB2),
onSecondaryContainer = Color(0xFF3C3836),
tertiary = Color(0xFF689D6A),
onTertiary = Color(0xFFFBF1C7),
tertiaryContainer = Color(0xFFD5C4A1),
onTertiaryContainer = Color(0xFF282828),
background = Color(0xFFFBF1C7),
onBackground = Color(0xFF282828),
surface = Color(0xFFF2E5BC),
onSurface = Color(0xFF282828),
surfaceVariant = Color(0xFFEBDBB2),
onSurfaceVariant = Color(0xFF7C6F64),
surfaceTint = Color(0xFFD65D0E),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF9F5D7),
surfaceContainer = Color(0xFFFBF1C7),
surfaceContainerHigh = Color(0xFFF2E5BC),
surfaceContainerHighest = Color(0xFFEBDBB2),
inverseSurface = Color(0xFF282828),
inverseOnSurface = Color(0xFFFBF1C7),
error = Color(0xFFCC241D),
onError = Color(0xFFFBF1C7),
errorContainer = Color(0xFFF9F5D7),
onErrorContainer = Color(0xFF9D0006),
outline = Color(0xFFA89984),
outlineVariant = Color(0xFFD5C4A1),
scrim = Color(0xFF3C3836),
)
private val GruvboxDarkBg0 = Color(0xFF282828)
private val GruvboxDarkBg1 = Color(0xFF3C3836)
private val GruvboxDarkBg2 = Color(0xFF504945)
private val GruvboxDarkBg3 = Color(0xFF665C54)
private val GruvboxDarkBg4 = Color(0xFF7C6F64)
private val GruvboxDarkFg1 = Color(0xFFEBDBB2)
private val GruvboxDarkFg2 = Color(0xFFD5C4A1)
private val GruvboxDarkFg3 = Color(0xFFBDAE93)
private val GruvboxGray = Color(0xFF928374) // same neutral gray in both modes
/** Gruvbox status colors (dark). */
val GruvboxDarkStatusColors =
HermesStatusColors(
success = Color(0xFFB8BB26),
successContainer = Color(0xFF32302F),
onSuccess = Color(0xFF282828),
warning = Color(0xFFFABD2F),
warningContainer = Color(0xFF3C3836),
onWarning = Color(0xFF282828),
error = Color(0xFFFB4934),
errorContainer = Color(0xFF282828),
onError = Color(0xFF282828),
info = Color(0xFF83A598),
infoContainer = Color(0xFF3C3836),
onInfo = Color(0xFF282828),
)
// "Bright" accents — the variants gruvbox uses against a dark background.
private val GruvboxBrightRed = Color(0xFFFB4934)
private val GruvboxBrightGreen = Color(0xFFB8BB26)
private val GruvboxBrightYellow = Color(0xFFFABD2F)
private val GruvboxBrightBlue = Color(0xFF83A598)
private val GruvboxBrightPurple = Color(0xFFD3869B)
private val GruvboxBrightOrange = Color(0xFFFE8019)
/** Gruvbox status colors (light). */
val GruvboxLightStatusColors =
HermesStatusColors(
success = Color(0xFF98971A),
successContainer = Color(0xFFEBDBB2),
onSuccess = Color(0xFFEBDBB2),
warning = Color(0xFFD79921),
warningContainer = Color(0xFFEBDBB2),
onWarning = Color(0xFFEBDBB2),
error = Color(0xFFCC241D),
errorContainer = Color(0xFFEBDBB2),
onError = Color(0xFFEBDBB2),
info = Color(0xFF458588),
infoContainer = Color(0xFFEBDBB2),
onInfo = Color(0xFFEBDBB2),
// ---------------------------------------------------------------------
// Gruvbox Light (medium contrast) — named swatches
// ---------------------------------------------------------------------
private val GruvboxLightBg0 = Color(0xFFFBF1C7)
private val GruvboxLightBg1 = Color(0xFFEBDBB2)
private val GruvboxLightBg2 = Color(0xFFD5C4A1)
private val GruvboxLightBg3 = Color(0xFFBDAE93)
private val GruvboxLightBg4 = Color(0xFFA89984)
private val GruvboxLightFg1 = Color(0xFF3C3836)
private val GruvboxLightFg2 = Color(0xFF504945)
private val GruvboxLightFg3 = Color(0xFF665C54)
// "Faded" accents — the muted variants gruvbox uses against a light
// background, so contrast holds up without the neon dark-mode saturation.
private val GruvboxFadedRed = Color(0xFF9D0006)
private val GruvboxFadedGreen = Color(0xFF79740E)
private val GruvboxFadedYellow = Color(0xFFB57614)
private val GruvboxFadedBlue = Color(0xFF076678)
private val GruvboxFadedPurple = Color(0xFF8F3F71)
private val GruvboxFadedOrange = Color(0xFFAF3A03)
/** Gruvbox theme — bright accents on dark bg0–bg4, faded accents on light bg0–bg4. */
val GruvboxTheme =
buildTheme(
dark =
PaletteColors(
primary = GruvboxBrightOrange,
onPrimary = GruvboxDarkBg0,
primaryContainer = GruvboxDarkBg4,
onPrimaryContainer = GruvboxDarkFg1,
secondary = GruvboxBrightBlue,
onSecondary = GruvboxDarkBg0,
secondaryContainer = GruvboxDarkBg3,
onSecondaryContainer = GruvboxDarkFg2,
tertiary = GruvboxBrightPurple,
onTertiary = GruvboxDarkBg0,
tertiaryContainer = GruvboxDarkBg4,
onTertiaryContainer = GruvboxDarkFg1,
background = GruvboxDarkBg0,
onBackground = GruvboxDarkFg1,
surface = GruvboxDarkBg0,
onSurface = GruvboxDarkFg1,
surfaceVariant = GruvboxDarkBg1,
onSurfaceVariant = GruvboxDarkFg3,
surfaceContainerLowest = GruvboxDarkBg0,
surfaceContainerLow = GruvboxDarkBg1,
surfaceContainer = GruvboxDarkBg2,
surfaceContainerHigh = GruvboxDarkBg3,
surfaceContainerHighest = GruvboxDarkBg4,
inverseSurface = GruvboxDarkFg1,
inverseOnSurface = GruvboxDarkBg0,
inversePrimary = GruvboxFadedOrange,
outline = GruvboxGray,
outlineVariant = GruvboxDarkBg3,
scrim = GruvboxDarkBg0,
status =
HermesStatusColors(
success = GruvboxBrightGreen,
successContainer = GruvboxDarkBg3,
onSuccess = GruvboxDarkBg0,
warning = GruvboxBrightYellow,
warningContainer = GruvboxDarkBg3,
onWarning = GruvboxDarkBg0,
error = GruvboxBrightRed,
errorContainer = GruvboxDarkBg3,
onError = GruvboxDarkBg0,
onErrorContainer = GruvboxDarkFg1,
info = GruvboxBrightBlue,
infoContainer = GruvboxDarkBg3,
onInfo = GruvboxDarkBg0,
),
),
light =
PaletteColors(
primary = GruvboxFadedOrange,
onPrimary = GruvboxLightBg0,
primaryContainer = GruvboxLightBg4,
onPrimaryContainer = GruvboxLightFg1,
secondary = GruvboxFadedBlue,
onSecondary = GruvboxLightBg0,
secondaryContainer = GruvboxLightBg3,
onSecondaryContainer = GruvboxLightFg2,
tertiary = GruvboxFadedPurple,
onTertiary = GruvboxLightBg0,
tertiaryContainer = GruvboxLightBg4,
onTertiaryContainer = GruvboxLightFg1,
background = GruvboxLightBg0,
onBackground = GruvboxLightFg1,
surface = GruvboxLightBg0,
onSurface = GruvboxLightFg1,
surfaceVariant = GruvboxLightBg1,
onSurfaceVariant = GruvboxLightFg3,
surfaceContainerLowest = GruvboxLightBg0,
surfaceContainerLow = GruvboxLightBg1,
surfaceContainer = GruvboxLightBg2,
surfaceContainerHigh = GruvboxLightBg3,
surfaceContainerHighest = GruvboxLightBg4,
inverseSurface = GruvboxLightFg1,
inverseOnSurface = GruvboxLightBg0,
inversePrimary = GruvboxBrightOrange,
outline = GruvboxGray,
outlineVariant = GruvboxLightBg3,
scrim = GruvboxLightBg0,
status =
HermesStatusColors(
success = GruvboxFadedGreen,
successContainer = GruvboxLightBg3,
onSuccess = GruvboxLightBg0,
warning = GruvboxFadedYellow,
warningContainer = GruvboxLightBg3,
onWarning = GruvboxLightBg0,
error = GruvboxFadedRed,
errorContainer = GruvboxLightBg3,
onError = GruvboxLightBg0,
onErrorContainer = GruvboxFadedRed,
info = GruvboxFadedBlue,
infoContainer = GruvboxLightBg3,
onInfo = GruvboxLightBg0,
),
),
)
@@ -1,118 +1,150 @@
package com.m57.hermescontrol.theme.presets
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.PaletteColors
import com.m57.hermescontrol.theme.buildTheme
/** Monochrome color scheme (dark). */
val MonochromeDarkColorScheme =
darkColorScheme(
primary = Color.White,
onPrimary = Color.Black,
primaryContainer = Color(0xFF404040),
onPrimaryContainer = Color.White,
inversePrimary = Color(0xFF121212),
secondary = Color(0xFFB0B0B0),
onSecondary = Color.Black,
secondaryContainer = Color(0xFF222222),
onSecondaryContainer = Color(0xFFE0E0E0),
tertiary = Color(0xFFD0D0D0),
onTertiary = Color.Black,
tertiaryContainer = Color(0xFF333333),
onTertiaryContainer = Color(0xFFE0E0E0),
background = Color(0xFF121212),
onBackground = Color(0xFFE0E0E0),
surface = Color(0xFF1E1E1E),
onSurface = Color(0xFFE0E0E0),
surfaceVariant = Color(0xFF2B2B2B),
onSurfaceVariant = Color(0xFFB0B0B0),
surfaceTint = Color.White,
surfaceContainerLowest = Color(0xFF0F0F0F),
surfaceContainerLow = Color(0xFF1A1A1A),
surfaceContainer = Color(0xFF1E1E1E),
surfaceContainerHigh = Color(0xFF252525),
surfaceContainerHighest = Color(0xFF2C2C2C),
inverseSurface = Color(0xFFE0E0E0),
inverseOnSurface = Color(0xFF121212),
error = Color(0xFFE0E0E0),
onError = Color.Black,
errorContainer = Color(0xFF404040),
onErrorContainer = Color(0xFFE0E0E0),
outline = Color(0xFF666666),
outlineVariant = Color(0xFF444444),
scrim = Color.Black,
)
// ---------------------------------------------------------------------
// Monochrome Dark — every swatch is R=G=B; roles are distinguished
// purely by lightness, not hue.
// ---------------------------------------------------------------------
/** Monochrome color scheme (light). */
val MonochromeLightColorScheme =
lightColorScheme(
primary = Color.Black,
onPrimary = Color.White,
primaryContainer = Color(0xFFC8C8C8),
onPrimaryContainer = Color.Black,
inversePrimary = Color(0xFFE0E0E0),
secondary = Color(0xFF4A4A4A),
onSecondary = Color.White,
secondaryContainer = Color(0xFFF0F0F0),
onSecondaryContainer = Color(0xFF111111),
tertiary = Color(0xFF333333),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFE8E8E8),
onTertiaryContainer = Color(0xFF111111),
background = Color(0xFFFFFFFF),
onBackground = Color(0xFF111111),
surface = Color(0xFFF5F5F5),
onSurface = Color(0xFF111111),
surfaceVariant = Color(0xFFE0E0E0),
onSurfaceVariant = Color(0xFF4A4A4A),
surfaceTint = Color.Black,
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFF8F8F8),
surfaceContainer = Color(0xFFF5F5F5),
surfaceContainerHigh = Color(0xFFEEEEEE),
surfaceContainerHighest = Color(0xFFE3E3E3),
inverseSurface = Color(0xFF111111),
inverseOnSurface = Color(0xFFE0E0E0),
error = Color(0xFF111111),
onError = Color.White,
errorContainer = Color(0xFFD6D6D6),
onErrorContainer = Color(0xFF111111),
outline = Color(0xFF888888),
outlineVariant = Color(0xFFCCCCCC),
scrim = Color.Black,
)
private val MonoDarkFloor = Color(0xFF0A0A0A) // surfaceContainerLowest
private val MonoDarkBg = Color(0xFF121212) // background, surface, containerLow, all "on*" ink
private val MonoDarkContainer = Color(0xFF1E1E1E) // surfaceContainer
private val MonoDarkContainerHigh = Color(0xFF2A2A2A) // surfaceContainerHigh, surfaceVariant, status containers
private val MonoDarkContainerHighest = Color(0xFF363636) // surfaceContainerHighest, primaryContainer
private val MonoDarkOutline = Color(0xFF808080)
private val MonoDarkOutlineVariant = Color(0xFF404040)
private val MonoDarkTextDim = Color(0xFFB3B3B3) // onSurfaceVariant
private val MonoDarkTextBright = Color(0xFFF2F2F2) // onBackground/onSurface/inverseSurface/primary/error
private val MonoDarkSecondary = Color(0xFFCCCCCC) // secondary accent + warning
private val MonoDarkTertiary = Color(0xFF999999) // tertiary accent + success
private val MonoDarkInfo = Color(0xFF808080) // info — same value as outline, distinct role
/** Monochrome status colors (dark). */
val MonochromeDarkStatusColors =
HermesStatusColors(
success = Color(0xFFE0E0E0),
successContainer = Color(0xFF2A2A2A),
onSuccess = Color(0xFF121212),
warning = Color(0xFFCCCCCC),
warningContainer = Color(0xFF333333),
onWarning = Color(0xFF121212),
error = Color(0xFFFFFFFF),
errorContainer = Color(0xFF404040),
onError = Color(0xFF121212),
info = Color(0xFFB0B0B0),
infoContainer = Color(0xFF242424),
onInfo = Color(0xFF121212),
)
// ---------------------------------------------------------------------
// Monochrome Light — the same ramp, inverted
// ---------------------------------------------------------------------
/** Monochrome status colors (light). */
val MonochromeLightStatusColors =
HermesStatusColors(
success = Color(0xFF222222),
successContainer = Color(0xFFE5E5E5),
onSuccess = Color(0xFFFFFFFF),
warning = Color(0xFF444444),
warningContainer = Color(0xFFE0E0E0),
onWarning = Color(0xFFFFFFFF),
error = Color(0xFF000000),
errorContainer = Color(0xFFD6D6D6),
onError = Color(0xFFFFFFFF),
info = Color(0xFF555555),
infoContainer = Color(0xFFEAEAEA),
onInfo = Color(0xFFFFFFFF),
private val MonoLightCeiling = Color(0xFFFFFFFF) // surfaceContainerLowest
private val MonoLightBg = Color(0xFFFAFAFA) // background, surface, containerLow, all "on*" ink
private val MonoLightContainer = Color(0xFFF0F0F0) // surfaceContainer
private val MonoLightContainerHigh = Color(0xFFE0E0E0) // surfaceContainerHigh, surfaceVariant, status containers
private val MonoLightContainerHighest = Color(0xFFD1D1D1) // surfaceContainerHighest, primaryContainer
private val MonoLightOutline = Color(0xFF808080)
private val MonoLightOutlineVariant = Color(0xFFCCCCCC)
private val MonoLightTextDim = Color(0xFF4D4D4D) // onSurfaceVariant
private val MonoLightTextBright = Color(0xFF121212) // onBackground/onSurface/inverseSurface/primary/error
private val MonoLightSecondary = Color(0xFF333333) // secondary accent + warning
private val MonoLightTertiary = Color(0xFF666666) // tertiary accent + success
private val MonoLightInfo = Color(0xFF808080) // info — same value as outline, distinct role
/**
* Monochrome theme — pure grayscale, no hue in any slot including status
* colors. `success`/`warning`/`error`/`info` are separated only by
* lightness (darkest/most emphasized = error, lightest/most muted = info),
* so anything that leans on this theme for status meaning should not rely
* on color alone — pair it with an icon, label, or weight change. This is
* the one real accessibility trade-off of going fully monochrome; every
* other preset keeps status colors hued specifically to avoid it.
*/
val MonochromeTheme =
buildTheme(
dark =
PaletteColors(
primary = MonoDarkTextBright,
onPrimary = MonoDarkBg,
primaryContainer = MonoDarkContainerHighest,
onPrimaryContainer = MonoDarkTextBright,
secondary = MonoDarkSecondary,
onSecondary = MonoDarkBg,
secondaryContainer = MonoDarkContainerHigh,
onSecondaryContainer = MonoDarkSecondary,
tertiary = MonoDarkTertiary,
onTertiary = MonoDarkBg,
tertiaryContainer = MonoDarkContainerHigh,
onTertiaryContainer = MonoDarkTertiary,
background = MonoDarkBg,
onBackground = MonoDarkTextBright,
surface = MonoDarkBg,
onSurface = MonoDarkTextBright,
surfaceVariant = MonoDarkContainerHigh,
onSurfaceVariant = MonoDarkTextDim,
surfaceContainerLowest = MonoDarkFloor,
surfaceContainerLow = MonoDarkBg,
surfaceContainer = MonoDarkContainer,
surfaceContainerHigh = MonoDarkContainerHigh,
surfaceContainerHighest = MonoDarkContainerHighest,
inverseSurface = MonoDarkTextBright,
inverseOnSurface = MonoDarkBg,
inversePrimary = MonoLightTextBright,
outline = MonoDarkOutline,
outlineVariant = MonoDarkOutlineVariant,
scrim = Color.Black,
status =
HermesStatusColors(
success = MonoDarkTertiary,
successContainer = MonoDarkContainerHigh,
onSuccess = MonoDarkBg,
warning = MonoDarkSecondary,
warningContainer = MonoDarkContainerHigh,
onWarning = MonoDarkBg,
error = MonoDarkTextBright,
errorContainer = MonoDarkContainerHigh,
onError = MonoDarkBg,
onErrorContainer = MonoDarkTextBright,
info = MonoDarkInfo,
infoContainer = MonoDarkContainerHigh,
onInfo = MonoDarkBg,
),
),
light =
PaletteColors(
primary = MonoLightTextBright,
onPrimary = MonoLightBg,
primaryContainer = MonoLightContainerHighest,
onPrimaryContainer = MonoLightTextBright,
secondary = MonoLightSecondary,
onSecondary = MonoLightBg,
secondaryContainer = MonoLightContainerHigh,
onSecondaryContainer = MonoLightSecondary,
tertiary = MonoLightTertiary,
onTertiary = MonoLightBg,
tertiaryContainer = MonoLightContainerHigh,
onTertiaryContainer = MonoLightTertiary,
background = MonoLightBg,
onBackground = MonoLightTextBright,
surface = MonoLightBg,
onSurface = MonoLightTextBright,
surfaceVariant = MonoLightContainerHigh,
onSurfaceVariant = MonoLightTextDim,
surfaceContainerLowest = MonoLightCeiling,
surfaceContainerLow = MonoLightBg,
surfaceContainer = MonoLightContainer,
surfaceContainerHigh = MonoLightContainerHigh,
surfaceContainerHighest = MonoLightContainerHighest,
inverseSurface = MonoLightTextBright,
inverseOnSurface = MonoLightBg,
inversePrimary = MonoDarkTextBright,
outline = MonoLightOutline,
outlineVariant = MonoLightOutlineVariant,
scrim = Color.Black,
status =
HermesStatusColors(
success = MonoLightTertiary,
successContainer = MonoLightContainerHigh,
onSuccess = MonoLightBg,
warning = MonoLightSecondary,
warningContainer = MonoLightContainerHigh,
onWarning = MonoLightBg,
error = MonoLightTextBright,
errorContainer = MonoLightContainerHigh,
onError = MonoLightBg,
onErrorContainer = MonoLightTextBright,
info = MonoLightInfo,
infoContainer = MonoLightContainerHigh,
onInfo = MonoLightBg,
),
),
)
@@ -1,118 +0,0 @@
package com.m57.hermescontrol.theme.presets
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color
import com.m57.hermescontrol.theme.HermesStatusColors
/** Neon Noir color scheme (dark). */
val NeonNoirDarkColorScheme =
darkColorScheme(
primary = Color(0xFFFF6BF4),
onPrimary = Color(0xFF1A001A),
primaryContainer = Color(0xFF3A003A),
onPrimaryContainer = Color(0xFFFFCCF2),
inversePrimary = Color(0xFFC440A8),
secondary = Color(0xFFA78BFA),
onSecondary = Color(0xFF0F0B1A),
secondaryContainer = Color(0xFF2B1F59),
onSecondaryContainer = Color(0xFFD9CCFF),
tertiary = Color(0xFF60F0FF),
onTertiary = Color(0xFF003B40),
tertiaryContainer = Color(0xFF004F59),
onTertiaryContainer = Color(0xFFBFFAFF),
background = Color(0xFF0F0B1A),
onBackground = Color(0xFFE8E0F0),
surface = Color(0xFF1A1530),
onSurface = Color(0xFFE8E0F0),
surfaceVariant = Color(0xFF2B2540),
onSurfaceVariant = Color(0xFF9A90B0),
surfaceTint = Color(0xFFFF6BF4),
surfaceContainerLowest = Color(0xFF0A0712),
surfaceContainerLow = Color(0xFF140F22),
surfaceContainer = Color(0xFF1A1530),
surfaceContainerHigh = Color(0xFF252040),
surfaceContainerHighest = Color(0xFF2F2A50),
inverseSurface = Color(0xFFE8E0F0),
inverseOnSurface = Color(0xFF0F0B1A),
error = Color(0xFFFF3D68),
onError = Color.White,
errorContainer = Color(0xFF3A0A14),
onErrorContainer = Color(0xFFFFB3C0),
outline = Color(0xFF3A3060),
outlineVariant = Color(0xFF2E2545),
scrim = Color(0xFF000000),
)
/** Neon Noir color scheme (light). */
val NeonNoirLightColorScheme =
lightColorScheme(
primary = Color(0xFFC440A8),
onPrimary = Color.White,
primaryContainer = Color(0xFFFFD9F2),
onPrimaryContainer = Color(0xFF3A003A),
inversePrimary = Color(0xFFFF6BF4),
secondary = Color(0xFF7C4DFF),
onSecondary = Color.White,
secondaryContainer = Color(0xFFEDE0FF),
onSecondaryContainer = Color(0xFF1E0066),
tertiary = Color(0xFF0098A6),
onTertiary = Color.White,
tertiaryContainer = Color(0xFFBFFAFF),
onTertiaryContainer = Color(0xFF003B40),
background = Color(0xFFF5F0FA),
onBackground = Color(0xFF0F0B1A),
surface = Color.White,
onSurface = Color(0xFF0F0B1A),
surfaceVariant = Color(0xFFEDE6F5),
onSurfaceVariant = Color(0xFF7A7090),
surfaceTint = Color(0xFFC440A8),
surfaceContainerLowest = Color(0xFFFFFFFF),
surfaceContainerLow = Color(0xFFFAF5FF),
surfaceContainer = Color(0xFFFDF8FF),
surfaceContainerHigh = Color(0xFFF0EDF5),
surfaceContainerHighest = Color(0xFFE6E0ED),
inverseSurface = Color(0xFF0F0B1A),
inverseOnSurface = Color(0xFFE8E0F0),
error = Color(0xFFB3261E),
onError = Color.White,
errorContainer = Color(0xFFF9DEDC),
onErrorContainer = Color(0xFF410E0B),
outline = Color(0xFFBFB8CC),
outlineVariant = Color(0xFFD9D2E3),
scrim = Color(0xFF000000),
)
/** Neon Noir status colors (dark). */
val NeonNoirDarkStatusColors =
HermesStatusColors(
success = Color(0xFF6FEA8B),
successContainer = Color(0xFF0A3A14),
onSuccess = Color(0xFF0F0B1A),
warning = Color(0xFFFFB347),
warningContainer = Color(0xFF3A2000),
onWarning = Color(0xFF0F0B1A),
error = Color(0xFFFF3D68),
errorContainer = Color(0xFF3A0A14),
onError = Color(0xFF0F0B1A),
info = Color(0xFF60F0FF),
infoContainer = Color(0xFF003B40),
onInfo = Color(0xFF0F0B1A),
)
/** Neon Noir status colors (light). */
val NeonNoirLightStatusColors =
HermesStatusColors(
success = Color(0xFF1B873A),
successContainer = Color(0xFFD7F5E0),
onSuccess = Color(0xFFFDF8FF),
warning = Color(0xFFB87800),
warningContainer = Color(0xFFFFEAB3),
onWarning = Color(0xFFFDF8FF),
error = Color(0xFFB3261E),
errorContainer = Color(0xFFF9DEDC),
onError = Color(0xFFFDF8FF),
info = Color(0xFF2E6FBD),
infoContainer = Color(0xFFD4E7FF),
onInfo = Color(0xFFFDF8FF),
)
@@ -0,0 +1,144 @@
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
// ---------------------------------------------------------------------
// Nord — named swatches, official numbering (nord0–nord15).
// Polar Night (0–3, dark neutrals) and Snow Storm (4–6, light neutrals)
// supply the surfaces; Frost (7–10) and Aurora (11–15) supply the
// accents. Unlike Gruvbox, Nord defines one accent set — not a
// separate bright/faded pair per mode — so the same accent swatches
// are reused in both palettes below.
// ---------------------------------------------------------------------
private val Nord0 = Color(0xFF2E3440) // Polar Night — darkest
private val Nord1 = Color(0xFF3B4252)
private val Nord2 = Color(0xFF434C5E)
private val Nord3 = Color(0xFF4C566A)
private val Nord4 = Color(0xFFD8DEE9) // Snow Storm
private val Nord5 = Color(0xFFE5E9F0)
private val Nord6 = Color(0xFFECEFF4) // Snow Storm — lightest
private val Nord8 = Color(0xFF88C0D0) // Frost — primary accent
private val Nord9 = Color(0xFF81A1C1) // Frost — secondary / info
private val Nord11 = Color(0xFFBF616A) // Aurora red — error
private val Nord13 = Color(0xFFEBCB8B) // Aurora yellow — warning
private val Nord14 = Color(0xFFA3BE8C) // Aurora green — success
private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary
/**
* Nord theme.
*
* Nord only ships 4 Polar Night tones and 3 Snow Storm tones, one tone
* short of the 5-tier surface-container ladder, so the two outermost
* tiers on each side share a value (surfaceContainerHigh ==
* surfaceContainerHighest) rather than inventing an off-palette hex.
*
* Frost/Aurora accents are pastel by design, so onPrimary/onSecondary/
* onTertiary/status "on" colors use Polar Night (dark text) in *both*
* modes — reusing Snow Storm for light-mode "on" text (the convention in
* the Catppuccin/Gruvbox presets) would fail contrast against colors like
* Aurora yellow, which stays light even without a separate light-mode
* variant.
*/
val NordTheme =
buildTheme(
dark =
PaletteColors(
primary = Nord8,
onPrimary = Nord0,
primaryContainer = Nord3,
onPrimaryContainer = Nord6,
secondary = Nord9,
onSecondary = Nord0,
secondaryContainer = Nord2,
onSecondaryContainer = Nord4,
tertiary = Nord15,
onTertiary = Nord0,
tertiaryContainer = Nord3,
onTertiaryContainer = Nord6,
background = Nord0,
onBackground = Nord6,
surface = Nord0,
onSurface = Nord6,
surfaceVariant = Nord1,
onSurfaceVariant = Nord4,
surfaceContainerLowest = Nord0,
surfaceContainerLow = Nord1,
surfaceContainer = Nord2,
surfaceContainerHigh = Nord3,
surfaceContainerHighest = Nord3,
inverseSurface = Nord6,
inverseOnSurface = Nord0,
inversePrimary = Nord8,
outline = Nord3,
outlineVariant = Nord2,
scrim = Nord0,
status =
HermesStatusColors(
success = Nord14,
successContainer = Nord2,
onSuccess = Nord0,
warning = Nord13,
warningContainer = Nord2,
onWarning = Nord0,
error = Nord11,
errorContainer = Nord2,
onError = Nord0,
onErrorContainer = Nord6,
info = Nord9,
infoContainer = Nord2,
onInfo = Nord0,
),
),
light =
PaletteColors(
primary = Nord8,
onPrimary = Nord0,
primaryContainer = Nord4,
onPrimaryContainer = Nord0,
secondary = Nord9,
onSecondary = Nord0,
secondaryContainer = Nord5,
onSecondaryContainer = Nord1,
tertiary = Nord15,
onTertiary = Nord0,
tertiaryContainer = Nord4,
onTertiaryContainer = Nord0,
background = Nord6,
onBackground = Nord0,
surface = Nord6,
onSurface = Nord0,
surfaceVariant = Nord5,
onSurfaceVariant = Nord2,
surfaceContainerLowest = Nord6,
surfaceContainerLow = Nord5,
surfaceContainer = Nord5,
surfaceContainerHigh = Nord4,
surfaceContainerHighest = Nord4,
inverseSurface = Nord0,
inverseOnSurface = Nord6,
inversePrimary = Nord8,
outline = Nord2,
outlineVariant = Nord4,
scrim = Nord6,
status =
HermesStatusColors(
success = Nord14,
successContainer = Nord5,
onSuccess = Nord0,
warning = Nord13,
warningContainer = Nord5,
onWarning = Nord0,
error = Nord11,
errorContainer = Nord5,
onError = Nord0,
onErrorContainer = Nord11,
info = Nord9,
infoContainer = Nord5,
onInfo = Nord0,
),
),
)
@@ -445,7 +445,7 @@ private fun SelfImprovementReviewCard(
val isSkill =
cleanText.contains("skill", ignoreCase = true) ||
cleanText.contains("SKILL.md", ignoreCase = true)
val icon = if (isSkill) "⚡" else "🧠"
val icon = if (isSkill) "🛠" else "🧠"
val title =
if (isSkill) {
"Self-Improvement Review • Skill Patched"
@@ -187,7 +187,7 @@ private fun TodoInspectionCard(todo: TodoItem) {
val statusSymbol =
when {
todo.isCompleted -> "✅"
todo.isInProgress -> "⚡"
todo.isInProgress -> "🔄"
todo.isCancelled -> "❌"
else -> "⭕"
}
@@ -225,7 +225,7 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
when {
indicator.isComplete -> "✅"
indicator.isFailed -> "❌"
else -> "⚡"
else -> "🔄"
}
Text(text = statusSymbol, fontSize = 16.sp)
Spacer(modifier = Modifier.width(8.dp))
@@ -344,7 +344,7 @@ private fun SelfImprovementSection(graph: LearningGraphResponse?) {
modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val icon = if (node.kind == "skill") "⚡" else "🧠"
val icon = if (node.kind == "skill") "🛠" else "🧠"
Text(text = icon, fontSize = 16.sp)
Spacer(modifier = Modifier.width(4.dp))
Column(modifier = Modifier.weight(1f)) {
@@ -129,7 +129,7 @@ internal fun AppearanceSection(
ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox)
ThemePreset.CATPPUCCIN -> stringResource(R.string.theme_preset_catppuccin)
ThemePreset.AMOLED -> stringResource(R.string.theme_preset_amoled)
ThemePreset.NEON_NOIR -> stringResource(R.string.theme_preset_neon_noir)
ThemePreset.NORD -> stringResource(R.string.theme_preset_nord)
},
)
}
@@ -173,9 +173,9 @@ internal fun AppearanceSection(
)
}
ThemePreset.NEON_NOIR -> {
ThemePreset.NORD -> {
stringResource(
R.string.theme_preset_neon_noir,
R.string.theme_preset_nord,
)
}
},
+2 -3
View File
@@ -49,7 +49,7 @@
<string name="billing_topup">충전</string>
<!-- Navigation & Drawer -->
<string name="nav_drawer_title">⚡ 헤르메스</string>
<string name="nav_drawer_title">헤르메스</string>
<string name="nav_drawer_subtitle">AI 에이전트 제어</string>
<string name="nav_drawer_section_converse">대화</string>
<string name="nav_drawer_section_automate">자동화</string>
@@ -148,7 +148,7 @@
<string name="settings_delay_100ms">100 ms</string>
<string name="settings_action_test_connection">연결 테스트</string>
<string name="settings_sec_about">정보</string>
<string name="settings_about_app_name">⚡ 헤르메스 모바일</string>
<string name="settings_about_app_name">헤르메스 모바일</string>
<string name="settings_about_version">버전</string>
<string name="settings_about_build">빌드</string>
<string name="settings_about_debug">디버그</string>
@@ -176,7 +176,6 @@
<string name="theme_preset_gruvbox">Gruvbox</string>
<string name="theme_preset_catppuccin">Catppuccin</string>
<string name="theme_preset_amoled">AMOLED 블랙 (다크 전용)</string>
<string name="theme_preset_neon_noir">네온 느와르</string>
<!-- Connect Screen -->
<string name="connect_selected_profile">프로필: %1$s</string>
+4 -4
View File
@@ -61,7 +61,7 @@
<string name="billing_topup">Top-up</string>
<!-- Navigation & Drawer -->
<string name="nav_drawer_title">⚡ Hermes</string>
<string name="nav_drawer_title">Hermes</string>
<string name="nav_drawer_subtitle">AI Agent Control</string>
<string name="nav_drawer_section_converse">Converse</string>
<string name="nav_drawer_section_automate">Automate</string>
@@ -163,7 +163,7 @@
<string name="settings_delay_100ms">100 ms</string>
<string name="settings_action_test_connection">Test Connection</string>
<string name="settings_sec_about">About</string>
<string name="settings_about_app_name">⚡ Hermes Mobile</string>
<string name="settings_about_app_name">Hermes Mobile</string>
<string name="settings_about_version">Version</string>
<string name="settings_about_build">Build</string>
<string name="settings_about_debug">Debug</string>
@@ -196,12 +196,12 @@
<string name="settings_item_use_dynamic_colors">Dynamic Colors</string>
<string name="settings_desc_use_dynamic_colors">Derive scheme from wallpaper (Android 12+)</string>
<string name="settings_item_theme_preset">Preset Theme</string>
<string name="theme_preset_default">Default (Voltage Purple)</string>
<string name="theme_preset_default">Default</string>
<string name="theme_preset_monochrome">Monochrome</string>
<string name="theme_preset_gruvbox">Gruvbox</string>
<string name="theme_preset_catppuccin">Catppuccin</string>
<string name="theme_preset_amoled">AMOLED Black (Dark only)</string>
<string name="theme_preset_neon_noir">Neon Noir</string>
<string name="theme_preset_nord">Nord</string>
<!-- Language -->
<string name="settings_item_language">Language</string>
@@ -0,0 +1,189 @@
package com.m57.hermescontrol.theme
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
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.GruvboxTheme
import com.m57.hermescontrol.theme.presets.MonochromeTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Guards the palette template's color invariants:
* 1. Every shipped error slot pair meets >= 3:1 WCAG contrast.
* 2. ThemeMode combinations are validated at construction time.
* 3. One-mode themes resolve and fall back to the default theme correctly.
*/
class ThemePaletteTest {
private val themes =
listOf(
ThemePreset.DEFAULT to DefaultTheme,
ThemePreset.MONOCHROME to MonochromeTheme,
ThemePreset.GRUVBOX to GruvboxTheme,
ThemePreset.CATPPUCCIN to CatppuccinTheme,
ThemePreset.AMOLED to AmoledTheme,
)
@Test
fun errorSlotPairsMeetContrastInEveryShippedMode() {
themes.forEach { (preset, theme) ->
listOf(true, false).forEach { dark ->
val scheme = theme.schemeFor(dark) ?: return@forEach
assertTrue(
"$preset dark=$dark onError/error contrast must be >= 3:1",
contrast(scheme.onError, scheme.error) >= 3f,
)
assertTrue(
"$preset dark=$dark onErrorContainer/errorContainer contrast must be >= 3:1",
contrast(scheme.onErrorContainer, scheme.errorContainer) >= 3f,
)
}
}
}
@Test
fun amoledShipsDarkOnly() {
assertTrue("AMOLED must ship a dark scheme", AmoledTheme.darkScheme != null)
assertTrue("AMOLED must not ship a light scheme", AmoledTheme.lightScheme == null)
assertTrue("AMOLED must ship dark status colors", AmoledTheme.darkStatus != null)
assertTrue("AMOLED must not ship light status colors", AmoledTheme.lightStatus == null)
}
@Test
fun everyPresetResolvesItsDeclaredModes() {
themes.forEach { (preset, theme) ->
listOf(true, false).forEach { dark ->
if (theme.schemeFor(dark) != null) {
assertTrue(
"$preset dark=$dark scheme must resolve",
resolveColorScheme(preset, darkTheme = dark) != null,
)
assertTrue(
"$preset dark=$dark status must resolve",
resolveStatusColors(preset, darkTheme = dark) != null,
)
}
}
}
}
@Test
fun amoledLightModeFallsBackToDefaultLight() {
val fallbackScheme = resolveColorScheme(ThemePreset.AMOLED, darkTheme = false)
val defaultLight = DefaultTheme.lightScheme!!
assertEquals(
"AMOLED light scheme must fall back to Default light background",
defaultLight.background,
fallbackScheme.background,
)
val fallbackStatus = resolveStatusColors(ThemePreset.AMOLED, darkTheme = false)
val defaultLightStatus = DefaultTheme.lightStatus!!
assertEquals(
"AMOLED light status must fall back to Default light success",
defaultLightStatus.success,
fallbackStatus.success,
)
// AMOLED dark resolves to its own palette, not the default.
val ownDark = AmoledTheme.darkScheme!!
assertEquals(
"AMOLED dark scheme must be its own",
ownDark.background,
resolveColorScheme(ThemePreset.AMOLED, darkTheme = true).background,
)
}
@Test
fun themePaletteRejectsIllegalModeCombinations() {
val colors = dummyColors()
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.FULL, null, null)
}
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.FULL, colors, null)
}
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.FULL, null, colors)
}
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.DARK_ONLY, null, null)
}
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.DARK_ONLY, null, colors)
}
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.LIGHT_ONLY, null, null)
}
assertThrows(IllegalArgumentException::class.java) {
ThemePalette(ThemeMode.LIGHT_ONLY, colors, null)
}
// Valid combinations must construct without throwing.
ThemePalette(ThemeMode.FULL, colors, colors)
ThemePalette(ThemeMode.DARK_ONLY, colors, null)
ThemePalette(ThemeMode.LIGHT_ONLY, null, colors)
}
private fun contrast(
a: Color,
b: Color,
): Float {
val lighter = maxOf(a.luminance(), b.luminance())
val darker = minOf(a.luminance(), b.luminance())
return (lighter + 0.05f) / (darker + 0.05f)
}
private fun dummyColors(): PaletteColors =
PaletteColors(
primary = Color.White,
onPrimary = Color.Black,
primaryContainer = Color.White,
onPrimaryContainer = Color.Black,
secondary = Color.White,
onSecondary = Color.Black,
secondaryContainer = Color.White,
onSecondaryContainer = Color.Black,
tertiary = Color.White,
onTertiary = Color.Black,
tertiaryContainer = Color.White,
onTertiaryContainer = Color.Black,
background = Color.White,
onBackground = Color.Black,
surface = Color.White,
onSurface = Color.Black,
surfaceVariant = Color.White,
onSurfaceVariant = Color.Black,
surfaceContainerLowest = Color.White,
surfaceContainerLow = Color.White,
surfaceContainer = Color.White,
surfaceContainerHigh = Color.White,
surfaceContainerHighest = Color.White,
inverseSurface = Color.Black,
inverseOnSurface = Color.White,
inversePrimary = Color.Black,
outline = Color.Gray,
outlineVariant = Color.Gray,
scrim = Color.Black,
status =
HermesStatusColors(
success = Color.Green,
successContainer = Color.White,
onSuccess = Color.Black,
warning = Color.Yellow,
warningContainer = Color.White,
onWarning = Color.Black,
error = Color.Red,
errorContainer = Color.White,
onError = Color.Black,
onErrorContainer = Color.Black,
info = Color.Blue,
infoContainer = Color.White,
onInfo = Color.Black,
),
)
}