Files
aiturk-mobil/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt
T
M57andM57 ab42e19b7f 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>
2026-08-08 15:58:25 +03:00

180 lines
6.8 KiB
Kotlin

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