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