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:
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user