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

@@ -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,
),
)
}