From ab42e19b7fb7daf19387e8a883964921af5564b5 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:58:25 +0300 Subject: [PATCH] theme(849): unify presets behind one palette template + bespoke AMOLED status colors (#852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- AGENTS.md | 10 +- .../java/com/m57/hermescontrol/Navigation.kt | 4 +- .../java/com/m57/hermescontrol/theme/Color.kt | 93 +----- .../hermescontrol/theme/HermesStatusColors.kt | 1 + .../hermescontrol/theme/PaletteTemplate.kt | 179 +++++++++++ .../com/m57/hermescontrol/theme/THEMES.md | 138 ++++---- .../java/com/m57/hermescontrol/theme/Theme.kt | 82 +++-- .../theme/presets/AmoledScheme.kt | 137 ++++---- .../theme/presets/CatppuccinScheme.kt | 252 ++++++++------- .../theme/presets/DefaultScheme.kt | 299 +++++++++--------- .../theme/presets/GruvboxScheme.kt | 251 ++++++++------- .../theme/presets/MonochromeScheme.kt | 250 ++++++++------- .../theme/presets/NeonNoirScheme.kt | 118 ------- .../hermescontrol/theme/presets/NordScheme.kt | 144 +++++++++ .../m57/hermescontrol/ui/chat/ChatBubble.kt | 2 +- .../components/SubagentInspectionSheet.kt | 4 +- .../hermescontrol/ui/memory/MemoryScreen.kt | 2 +- .../settings/components/AppearanceSection.kt | 6 +- app/src/main/res/values-ko/strings.xml | 5 +- app/src/main/res/values/strings.xml | 8 +- .../hermescontrol/theme/ThemePaletteTest.kt | 189 +++++++++++ 21 files changed, 1312 insertions(+), 862 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt delete mode 100644 app/src/main/java/com/m57/hermescontrol/theme/presets/NeonNoirScheme.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt diff --git a/AGENTS.md b/AGENTS.md index 072afd78..a302c8b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 4bfff7e1..6347c4c4 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -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, ), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index 01bdc16e..968f391b 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt b/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt index f36ecd19..8dee742b 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt b/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt new file mode 100644 index 00000000..7d145e94 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt @@ -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, + ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md index fda4dc1a..5180da53 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md +++ b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md @@ -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/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/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/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`. diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt index 6af2f375..66db1e83 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/AmoledScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/AmoledScheme.kt index cf3efb35..e8954976 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/AmoledScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/AmoledScheme.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/CatppuccinScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/CatppuccinScheme.kt index 29d06f93..fc23d57d 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/CatppuccinScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/CatppuccinScheme.kt @@ -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, + ), + ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/DefaultScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/DefaultScheme.kt index e7f3604d..fcb7f67c 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/DefaultScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/DefaultScheme.kt @@ -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, + ), + ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/GruvboxScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/GruvboxScheme.kt index fa5de2d0..f56965f6 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/GruvboxScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/GruvboxScheme.kt @@ -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, + ), + ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/MonochromeScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/MonochromeScheme.kt index bc454e2c..f4e1d13f 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/MonochromeScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/MonochromeScheme.kt @@ -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, + ), + ), ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/NeonNoirScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/NeonNoirScheme.kt deleted file mode 100644 index f1140f12..00000000 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/NeonNoirScheme.kt +++ /dev/null @@ -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), - ) diff --git a/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt b/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt new file mode 100644 index 00000000..599bdda1 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt @@ -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, + ), + ), + ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index 03af5914..70599936 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -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" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt index a24a1304..d14de01e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt @@ -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)) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt index 3e4229d4..a730ce38 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt @@ -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)) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt index ee00abb4..7c087115 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt @@ -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, ) } }, diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 84998fae..11902b45 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -49,7 +49,7 @@ 충전 - ⚡ 헤르메스 + 헤르메스 AI 에이전트 제어 대화 자동화 @@ -148,7 +148,7 @@ 100 ms 연결 테스트 정보 - ⚡ 헤르메스 모바일 + 헤르메스 모바일 버전 빌드 디버그 @@ -176,7 +176,6 @@ Gruvbox Catppuccin AMOLED 블랙 (다크 전용) - 네온 느와르 프로필: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 92e13127..0e38b0f5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -61,7 +61,7 @@ Top-up - ⚡ Hermes + Hermes AI Agent Control Converse Automate @@ -163,7 +163,7 @@ 100 ms Test Connection About - ⚡ Hermes Mobile + Hermes Mobile Version Build Debug @@ -196,12 +196,12 @@ Dynamic Colors Derive scheme from wallpaper (Android 12+) Preset Theme - Default (Voltage Purple) + Default Monochrome Gruvbox Catppuccin AMOLED Black (Dark only) - Neon Noir + Nord Language diff --git a/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt b/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt new file mode 100644 index 00000000..5d7dd87f --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt @@ -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, + ), + ) +}