From 56bb3a88c2c8e0abef733a56dc848146f6bc2afd Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:14:56 +0300 Subject: [PATCH] fix(theme): complete color and component contrast contracts (#1417) --- DESIGN.md | 9 +- .../ui/common/StatusBadgeColorTest.kt | 81 +++++ .../ui/common/ThemeComponentGalleryTest.kt | 238 +++++++++++++++ .../hermescontrol/theme/HermesStatusColors.kt | 9 +- .../hermescontrol/theme/PaletteTemplate.kt | 45 +++ .../com/m57/hermescontrol/theme/THEMES.md | 67 +++-- .../java/com/m57/hermescontrol/theme/Theme.kt | 23 +- .../m57/hermescontrol/theme/ThemeRegistry.kt | 26 ++ .../theme/presets/AmoledScheme.kt | 14 + .../theme/presets/CatppuccinScheme.kt | 76 +++-- .../theme/presets/DefaultScheme.kt | 30 +- .../theme/presets/GruvboxScheme.kt | 79 +++-- .../theme/presets/MonochromeScheme.kt | 35 ++- .../hermescontrol/theme/presets/NordScheme.kt | 76 +++-- .../ui/common/SharedComponents.kt | 39 ++- .../components/SessionLiveStatusIndicator.kt | 22 +- .../ui/settings/components/AboutSection.kt | 6 - .../settings/components/AppearanceSection.kt | 96 ++---- .../ui/settings/components/BehaviorSection.kt | 16 - app/src/main/res/values/strings.xml | 1 + .../theme/ThemeComponentContrastTest.kt | 64 ++++ .../hermescontrol/theme/ThemePaletteTest.kt | 279 ++++++++++++------ 22 files changed, 1013 insertions(+), 318 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/common/StatusBadgeColorTest.kt create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/common/ThemeComponentGalleryTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt diff --git a/DESIGN.md b/DESIGN.md index 86672bcc..9908486b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -23,7 +23,7 @@ Android layout dimensions use `dp`; text sizes, line heights, and tracking use ` | Concern | Authoritative source | | --- | --- | -| Presets and mode selection | [Theme.kt](app/src/main/java/com/m57/hermescontrol/theme/Theme.kt) | +| Presets and mode selection | [ThemeRegistry.kt](app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt), [Theme.kt](app/src/main/java/com/m57/hermescontrol/theme/Theme.kt) | | Material slot mapping | [PaletteTemplate.kt](app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt) | | Palette values | [presets/](app/src/main/java/com/m57/hermescontrol/theme/presets/) | | Semantic status model | [HermesStatusColors.kt](app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt) | @@ -144,8 +144,11 @@ passed an accessibility audit. - **Narrow layouts**: Check long localized labels, keyboard-open states, and scrolling at small widths; density must not hide essential actions. [ThemePaletteTest.kt](app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt) -currently guards error slot pairs, full-bleed prose/header contrast, and theme-mode -invariants. Those tests are useful but do not establish contrast or accessibility +guards normal-text slot pairs, full-bleed prose/header contrast, complete Material +role mapping, registry coverage, fixed-role mode invariance, and theme-mode invariants. +[ThemeComponentContrastTest.kt](app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt) +and the instrumented component gallery also guard enabled Material control states. +Those tests are useful but do not establish contrast or accessibility compliance for every rendered component, alpha blend, or dynamic palette. Record device/emulator steps and observed results for changed UI; explicitly name any gate that could not be verified. diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/common/StatusBadgeColorTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/StatusBadgeColorTest.kt new file mode 100644 index 00000000..a115fa7a --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/StatusBadgeColorTest.kt @@ -0,0 +1,81 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.SessionLiveStatus +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.theme.ThemePreference +import com.m57.hermescontrol.theme.ThemePreset +import com.m57.hermescontrol.theme.resolveStatusColors +import com.m57.hermescontrol.ui.sessions.components.SessionLiveStatusIndicator +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class StatusBadgeColorTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun latteBadgesRenderStatusFillsAndMatchingOnColors() { + val types = StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL } + composeTestRule.setContent { + HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.CATPPUCCIN) { + Column { + types.forEach { type -> + StatusBadge(type.name, type, Modifier.testTag("badge_$type")) + } + } + } + } + val colors = resolveStatusColors(ThemePreset.CATPPUCCIN, darkTheme = false) + types.forEach { type -> + val (background, foreground) = requireNotNull(statusBadgeColors(type, colors)) + assertRenderedPair("badge_$type", background.toArgb(), foreground.toArgb()) + } + } + + @Test + fun nordLiveIndicatorsRenderStatusFillsAndMatchingOnColors() { + composeTestRule.setContent { + HermesControlTheme(themePreference = ThemePreference.LIGHT, themePreset = ThemePreset.NORD) { + Column { + SessionLiveStatusIndicator(SessionLiveStatus.WORKING, "working") + SessionLiveStatusIndicator(SessionLiveStatus.WAITING, "waiting") + } + } + } + val colors = resolveStatusColors(ThemePreset.NORD, darkTheme = false) + assertRenderedPair("session_live_status_working", colors.success.toArgb(), colors.onSuccess.toArgb()) + assertRenderedPair("session_live_status_waiting", colors.warning.toArgb(), colors.onWarning.toArgb()) + } + + private fun assertRenderedPair( + tag: String, + background: Int, + foreground: Int, + ) { + // PR #1417: verify pixels as well as the palette mapping, so a renderer + // switching back to accent-on-container cannot leave the contrast test green. + val pixels = composeTestRule.onNodeWithTag(tag).captureToImage().toPixelMap() + assertEquals("$tag must render the status fill", background, pixels[pixels.width / 2, 1].toArgb()) + assertTrue( + "$tag must render the matching on-status text/icon", + (0 until pixels.height).any { y -> + (0 until pixels.width).any { x -> pixels[x, y].toArgb() == foreground } + }, + ) + } +} diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/common/ThemeComponentGalleryTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/ThemeComponentGalleryTest.kt new file mode 100644 index 00000000..091862e4 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/common/ThemeComponentGalleryTest.kt @@ -0,0 +1,238 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.unit.dp +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.theme.ThemePreference +import com.m57.hermescontrol.theme.ThemePreset +import com.m57.hermescontrol.theme.ThemeRegistry +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized + +@RunWith(Parameterized::class) +@MediumTest +class ThemeComponentGalleryTest( + private val preset: ThemePreset, + private val dark: Boolean, +) { + @get:Rule + val composeTestRule = createComposeRule() + + private var samples = emptyList() + + @Test + fun enabledControlsRenderTheirAccessibleDefaultStateColors() { + composeTestRule.setContent { + HermesControlTheme( + themePreset = preset, + themePreference = if (dark) ThemePreference.DARK else ThemePreference.LIGHT, + ) { + Gallery() + } + } + composeTestRule.waitForIdle() + // Assert the public defaults from the installed Compose version, not a copied token table. + assertTrue("gallery must publish its state contracts", samples.isNotEmpty()) + samples.forEach { sample -> + val a = sample.foreground.luminance() + val b = sample.background.luminance() + val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f) + assertTrue("$preset dark=$dark ${sample.tag}: $ratio < ${sample.minimum}", ratio >= sample.minimum) + } + // Pixel evidence catches defaults that differ from the actual rendered state (including focus). + samples.groupBy { it.tag }.forEach { (tag, contracts) -> + val pixels = + composeTestRule + .onNodeWithTag(tag) + .performScrollTo() + .captureToImage() + .toPixelMap() + val rendered = + buildSet { + for (y in 0 until pixels.height) { + for (x in 0 until pixels.width) add(pixels[x, y].toArgb()) + } + } + contracts.forEach { sample -> + assertTrue("$preset dark=$dark $tag must render its foreground", sample.foreground.toArgb() in rendered) + if (sample.checkBackground) { + assertTrue("$preset dark=$dark $tag must render its fill", sample.background.toArgb() in rendered) + } + } + } + } + + @Composable + private fun Gallery() { + val c = MaterialTheme.colorScheme + val switches = SwitchDefaults.colors() + val radios = RadioButtonDefaults.colors() + val checks = CheckboxDefaults.colors() + val sliders = SliderDefaults.colors() + val fields = OutlinedTextFieldDefaults.colors() + val segments = SegmentedButtonDefaults.colors() + val focus = remember { FocusRequester() } + SideEffect { + samples = + listOf( + Sample("switch_on", switches.checkedThumbColor, switches.checkedTrackColor, checkBackground = true), + Sample("switch_on", switches.checkedTrackColor, c.surface), + Sample( + "switch_off", + switches.uncheckedThumbColor, + switches.uncheckedTrackColor, + checkBackground = true, + ), + Sample("switch_off", switches.uncheckedBorderColor, c.surface), + Sample("radio_on", radios.selectedColor, c.surface), + Sample("radio_off", radios.unselectedColor, c.surface), + Sample("check_on", checks.checkedCheckmarkColor, checks.checkedBoxColor, checkBackground = true), + Sample("check_on", checks.checkedBorderColor, c.surface), + Sample("check_off", checks.uncheckedBorderColor, c.surface), + Sample("slider", sliders.activeTrackColor, sliders.inactiveTrackColor, checkBackground = true), + Sample( + "field_normal", + fields.unfocusedIndicatorColor, + fields.unfocusedContainerColor.compositeOver(c.surface), + ), + Sample("field_normal", fields.unfocusedLabelColor, c.surface, 4.5f), + Sample( + "field_focus", + fields.focusedIndicatorColor, + fields.focusedContainerColor.compositeOver(c.surface), + ), + Sample("field_focus", fields.focusedLabelColor, c.surface, 4.5f), + Sample( + "field_error", + fields.errorIndicatorColor, + fields.errorContainerColor.compositeOver(c.surface), + ), + Sample("field_error", fields.errorSupportingTextColor, c.surface, 4.5f), + Sample("segment_on", segments.activeContentColor, segments.activeContainerColor, 4.5f, true), + Sample("segment_on", segments.activeBorderColor, segments.activeContainerColor), + Sample( + "segment_off", + segments.inactiveContentColor, + segments.inactiveContainerColor.compositeOver(c.surface), + 4.5f, + ), + Sample("segment_off", segments.inactiveBorderColor, c.surface), + Sample("primary_text", c.primary, c.surface, 4.5f), + Sample("error_text", c.error, c.surface, 4.5f), + ) + } + Surface(color = c.surface, modifier = Modifier.fillMaxSize()) { + Column(Modifier.verticalScroll(rememberScrollState()).padding(8.dp)) { + Text("$preset ${if (dark) "dark" else "light"}") + Row { + Switch(true, {}, Modifier.testTag("switch_on")) + Switch(false, {}, Modifier.testTag("switch_off")) + RadioButton(true, {}, Modifier.testTag("radio_on")) + RadioButton(false, {}, Modifier.testTag("radio_off")) + } + Row { + Checkbox(true, {}, Modifier.testTag("check_on")) + Checkbox(false, {}, Modifier.testTag("check_off")) + } + Slider(0.5f, {}, Modifier.testTag("slider")) + OutlinedTextField("Value", { + }, Modifier.fillMaxWidth().testTag("field_normal"), readOnly = true, label = { Text("Normal") }) + OutlinedTextField( + "Value", + { + }, + Modifier + .fillMaxWidth() + .focusRequester( + focus, + ).testTag("field_focus"), + readOnly = true, + label = { Text("Focused") }, + ) + OutlinedTextField("Value", { + }, Modifier.fillMaxWidth().testTag("field_error"), readOnly = true, isError = true, label = { + Text("Error") + }, supportingText = { Text("Error text") }) + SingleChoiceSegmentedButtonRow { + SegmentedButton( + true, + {}, + SegmentedButtonDefaults.itemShape(0, 2), + Modifier.testTag("segment_on"), + ) { Text("Selected") } + SegmentedButton( + false, + { + }, + SegmentedButtonDefaults.itemShape( + 1, + 2, + ), + Modifier.testTag("segment_off"), + ) { Text("Unselected") } + } + Text("Primary foreground", Modifier.testTag("primary_text"), color = c.primary) + Text("Error foreground", Modifier.testTag("error_text"), color = c.error) + } + } + LaunchedEffect(Unit) { focus.requestFocus() } + } + + private data class Sample( + val tag: String, + val foreground: Color, + val background: Color, + val minimum: Float = 3f, + val checkBackground: Boolean = false, + ) + + companion object { + @JvmStatic + @Parameterized.Parameters(name = "{0} dark={1}") + fun presets(): List> = + ThemeRegistry.flatMap { (preset, _) -> listOf(true, false).map { arrayOf(preset, it) } } + } +} 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 8dee742b..a569b4a2 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt @@ -10,8 +10,13 @@ import androidx.compose.ui.graphics.luminance * Material You changes the *primary* / *surface* palette based on the user's * wallpaper, but success / warning / error / info colours must stay * semantically correct (green = good, red = bad) regardless of what the - * wallpaper-derived palette would choose. This struct is provided via - * [LocalHermesStatusColors] and should be used for all status indicators, + * wallpaper-derived palette would choose. + * + * `onSuccess`/`onWarning`/`onError`/`onInfo` are foregrounds on the matching + * status fills, not on their container colors. Reusable status badges use + * these fill/on pairs; error-container content uses `onErrorContainer`. + * + * Use [LocalHermesStatusColors] for all status indicators, * badges, and semantic feedback colours. */ data class HermesStatusColors( diff --git a/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt b/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt index 7d145e94..e7034bf2 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt @@ -28,6 +28,9 @@ enum class ThemeMode { FULL, DARK_ONLY, LIGHT_ONLY } * * The Material error slots are derived from [status] — the theme author * defines semantic colors once and the scheme inherits them. + * [primary] and the status error are also used directly for text on surfaces. + * [outline] must contrast with [surface] and [surfaceContainerHighest], which + * Material uses as the unchecked switch track. */ data class PaletteColors( val primary: Color, @@ -48,6 +51,20 @@ data class PaletteColors( val onSurface: Color, val surfaceVariant: Color, val onSurfaceVariant: Color, + val surfaceDim: Color, + val surfaceBright: Color, + val primaryFixed: Color, + val primaryFixedDim: Color, + val onPrimaryFixed: Color, + val onPrimaryFixedVariant: Color, + val secondaryFixed: Color, + val secondaryFixedDim: Color, + val onSecondaryFixed: Color, + val onSecondaryFixedVariant: Color, + val tertiaryFixed: Color, + val tertiaryFixedDim: Color, + val onTertiaryFixed: Color, + val onTertiaryFixedVariant: Color, val surfaceContainerLowest: Color, val surfaceContainerLow: Color, val surfaceContainer: Color, @@ -123,6 +140,20 @@ private fun darkSchemeOf(c: PaletteColors): ColorScheme = surfaceVariant = c.surfaceVariant, onSurfaceVariant = c.onSurfaceVariant, surfaceTint = c.primary, + surfaceDim = c.surfaceDim, + surfaceBright = c.surfaceBright, + primaryFixed = c.primaryFixed, + primaryFixedDim = c.primaryFixedDim, + onPrimaryFixed = c.onPrimaryFixed, + onPrimaryFixedVariant = c.onPrimaryFixedVariant, + secondaryFixed = c.secondaryFixed, + secondaryFixedDim = c.secondaryFixedDim, + onSecondaryFixed = c.onSecondaryFixed, + onSecondaryFixedVariant = c.onSecondaryFixedVariant, + tertiaryFixed = c.tertiaryFixed, + tertiaryFixedDim = c.tertiaryFixedDim, + onTertiaryFixed = c.onTertiaryFixed, + onTertiaryFixedVariant = c.onTertiaryFixedVariant, surfaceContainerLowest = c.surfaceContainerLowest, surfaceContainerLow = c.surfaceContainerLow, surfaceContainer = c.surfaceContainer, @@ -161,6 +192,20 @@ private fun lightSchemeOf(c: PaletteColors): ColorScheme = surfaceVariant = c.surfaceVariant, onSurfaceVariant = c.onSurfaceVariant, surfaceTint = c.primary, + surfaceDim = c.surfaceDim, + surfaceBright = c.surfaceBright, + primaryFixed = c.primaryFixed, + primaryFixedDim = c.primaryFixedDim, + onPrimaryFixed = c.onPrimaryFixed, + onPrimaryFixedVariant = c.onPrimaryFixedVariant, + secondaryFixed = c.secondaryFixed, + secondaryFixedDim = c.secondaryFixedDim, + onSecondaryFixed = c.onSecondaryFixed, + onSecondaryFixedVariant = c.onSecondaryFixedVariant, + tertiaryFixed = c.tertiaryFixed, + tertiaryFixedDim = c.tertiaryFixedDim, + onTertiaryFixed = c.onTertiaryFixed, + onTertiaryFixedVariant = c.onTertiaryFixedVariant, surfaceContainerLowest = c.surfaceContainerLowest, surfaceContainerLow = c.surfaceContainerLow, surfaceContainer = c.surfaceContainer, 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 01f7dd11..12f57d82 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md +++ b/app/src/main/java/com/m57/hermescontrol/theme/THEMES.md @@ -12,7 +12,8 @@ All theme code is under: ``` app/src/main/java/com/m57/hermescontrol/theme/ -├── Theme.kt # dispatcher — preset lookup + mode fallback +├── Theme.kt # dispatcher — mode fallback + dynamic colors +├── ThemeRegistry.kt # single preset/palette registry ├── 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*) @@ -55,7 +56,14 @@ val MyTheme = 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. +colors once. `surfaceTint` comes from `primary`. Every other Material color role +is required explicitly, including bright/dim surfaces and all fixed accent roles. +No role may inherit Material baseline colors. Fixed accent and foreground roles +stay identical across a preset’s shipped modes; Fixed/Dim may share an official +swatch when the source palette has no accessible tonal ladder. + +Prefer upstream swatches. Document every derived color beside its declaration +with the official pairing’s measured contrast and why a derived role is needed. ## Preset conventions @@ -69,8 +77,33 @@ colors once. 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. +- Normal-text foreground/background pairs, including accent containers, fixed + accents, semantic status fills, errors, and the surface ladder, are enforced at **>= 4.5:1 contrast** + by `ThemePaletteTest`. The separate full-bleed header guard remains >= 3:1. + +Reusable `StatusBadge` and `SessionLiveStatusIndicator` use a shared +`statusBadgeColors` mapping: status fills (`success`, `warning`, `error`, `info`) +with their matching `on*` text/icons. `onSuccess` is not a foreground token for +`successContainer`. Palette tests exercise this exact renderer mapping in every +resolved mode, including fallback modes; neutral badges use Material tokens. + +Enabled Material components also have a component-state contract: +- Graphics/boundaries target >= 3:1: switch thumbs/tracks and borders, radio and + checkbox states, slider active/inactive tracks, text-field borders, and segmented borders. +- Primary/error text and field labels on surface/background target >= 4.5:1. +- Segmented content uses the corresponding container foreground at >= 4.5:1. + +`ThemeComponentContrastTest` checks the pinned Material role combinations in every +resolved preset/mode. `ThemeComponentGalleryTest` reads the installed Compose +public color defaults and verifies rendered pixels for all twelve preset/mode +combinations, including focused/error fields. Disabled controls are deliberately +excluded from the enabled contrast contract; Material uses opacity to mute them. +Settings switches use Material defaults (`primary` track / `onPrimary` thumb), +not the reversed `primaryContainer` track / `primary` thumb combination. + +Outlines must contrast against both surface and the unchecked switch track +(`surfaceContainerHighest`). Named palettes may need documented derived primary +or error tones for direct text; fixed roles still preserve their upstream accents. ## Theme modes @@ -87,37 +120,33 @@ 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, `themeFor()` returns the preset's `ThemePalette`, then the scheme / -status are read off it (with the mode fallback above): +`ThemeRegistry.kt` maps every `ThemePreset` to one `ThemePalette`. Resolution, +UI selection, and palette tests use this registry. An exact enum/registry coverage +test rejects missing, duplicate, or reordered entries. Android string resources +stay in the UI’s exhaustive label mapping, outside the palette model. -```kotlin -private fun themeFor(preset: ThemePreset): ThemePalette = when (preset) { - ThemePreset.DEFAULT -> DefaultTheme - ThemePreset.MONOCHROME -> MonochromeTheme - // …one line per preset… -} -``` +`Theme.kt` reads `preset.palette()` and applies the mode fallback above. Dynamic (Material You) color on API 31+ can optionally override the preset scheme when `useDynamicColors = true` (defaults to `false`). Semantic status colors are always resolved from the -active preset via `LocalHermesStatusColors`. +active preset via `LocalHermesStatusColors`. The preset selector stays enabled +and explains that it controls status colors while Material You supplies app colors. ## Adding a new theme 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. +2. **Register** the new palette in `ThemeRegistry.kt`. 3. **Add the enum entry** `MY_THEME` to `ThemePreset` in `Theme.kt`. -4. **Wire the UI**: add the label + selection in +4. **Wire the UI**: add the exhaustive label mapping in `ui/settings/components/AppearanceSection.kt` (and any string resource). 5. **Verify**: ```bash ./gradlew ktlintCheck testDebugUnitTest ``` - `ThemePaletteTest` asserts >= 3:1 contrast on every shipped mode's error - slot pairs and the ThemeMode invariants — it must stay green. + `ThemePaletteTest` checks text contrast, complete Material slot mapping, + mode-independent fixed roles, registry coverage, and mode/fallback invariants. ## Conventions 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 f9ce67ff..7931c9ad 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Theme.kt @@ -10,12 +10,7 @@ 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.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 @@ -28,20 +23,6 @@ val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM } val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT } val LocalChatFontScale = compositionLocalOf { 1.0f } -/** - * 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. * @@ -53,7 +34,7 @@ internal fun resolveColorScheme( preset: ThemePreset, darkTheme: Boolean, ): ColorScheme { - val theme = themeFor(preset) + val theme = preset.palette() // DefaultTheme is FULL — its scheme is never null for either mode. return theme.schemeFor(darkTheme) ?: requireNotNull(DefaultTheme.schemeFor(darkTheme)) } @@ -66,7 +47,7 @@ internal fun resolveStatusColors( preset: ThemePreset, darkTheme: Boolean, ): HermesStatusColors { - val theme = themeFor(preset) + val theme = preset.palette() // DefaultTheme is FULL — its status colors are never null for either mode. return theme.statusFor(darkTheme) ?: requireNotNull(DefaultTheme.statusFor(darkTheme)) } diff --git a/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt b/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt new file mode 100644 index 00000000..9d475bf8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.theme + +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 + +internal data class ThemeDefinition( + val preset: ThemePreset, + val palette: ThemePalette, +) + +// Regression theme-contract: selection, resolution, and tests share this registry. +internal val ThemeRegistry = + listOf( + ThemeDefinition(ThemePreset.DEFAULT, DefaultTheme), + ThemeDefinition(ThemePreset.MONOCHROME, MonochromeTheme), + ThemeDefinition(ThemePreset.GRUVBOX, GruvboxTheme), + ThemeDefinition(ThemePreset.CATPPUCCIN, CatppuccinTheme), + ThemeDefinition(ThemePreset.AMOLED, AmoledTheme), + ThemeDefinition(ThemePreset.NORD, NordTheme), + ) + +internal fun ThemePreset.palette(): ThemePalette = ThemeRegistry.single { it.preset == this }.palette 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 e8954976..7ee9b189 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 @@ -59,6 +59,20 @@ val AmoledTheme = onSurface = AmoledTextBright, surfaceVariant = AmoledContainerHigh, onSurfaceVariant = AmoledTextDim, + surfaceDim = AmoledFloor, + surfaceBright = AmoledContainerHighest, + primaryFixed = AmoledTextBright, + primaryFixedDim = AmoledTextBright, + onPrimaryFixed = AmoledFloor, + onPrimaryFixedVariant = AmoledFloor, + secondaryFixed = AmoledSecondary, + secondaryFixedDim = AmoledSecondary, + onSecondaryFixed = AmoledFloor, + onSecondaryFixedVariant = AmoledFloor, + tertiaryFixed = AmoledTertiary, + tertiaryFixedDim = AmoledTertiary, + onTertiaryFixed = AmoledFloor, + onTertiaryFixedVariant = AmoledFloor, surfaceContainerLowest = AmoledFloor, surfaceContainerLow = AmoledFloor, surfaceContainer = AmoledContainer, 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 fc23d57d..11176bfd 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 @@ -19,7 +19,6 @@ 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) @@ -31,24 +30,33 @@ private val MochaCrust = Color(0xFF11111B) // --------------------------------------------------------------------- private val LatteMauve = Color(0xFF8839EF) + +// Derived role, not an upstream Catppuccin swatch: Latte Text/Pink is 3.02:1 +// and Text/Green is 2.39:1. Deeper ink reaches 5.88:1 and 4.65:1 respectively. +private val LatteDeepInk = Color(0xFF202334) + +// Derived role, not an upstream Catppuccin swatch: Latte Base/Blue is only 4.34:1. +// White/Blue reaches 4.94:1 for normal text. +private val LatteOnBlue = Color(0xFFFFFFFF) 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) + +// Official Latte Subtext0 keeps borders visible against Surface0 (3.20:1). private val LatteSubtext0 = Color(0xFF6C6F85) -private val LatteOverlay0 = Color(0xFF9CA0B0) +private val LatteText = Color(0xFF4C4F69) 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) +// Regression theme-contract: fixed accents use Mocha in both modes. Fixed/Dim +// share official accents rather than inventing tonal steps; on-roles use Crust. +// Latte surface containers stop at Surface0 so normal text stays >= 4.5:1. +// Upstream: https://github.com/catppuccin/palette/blob/main/palette.json /** Catppuccin theme — Mocha (dark) / Latte (light). */ val CatppuccinTheme = @@ -73,6 +81,20 @@ val CatppuccinTheme = onSurface = MochaText, surfaceVariant = MochaSurface0, onSurfaceVariant = MochaSubtext0, + surfaceDim = MochaBase, + surfaceBright = MochaSurface1, + primaryFixed = MochaMauve, + primaryFixedDim = MochaMauve, + onPrimaryFixed = MochaCrust, + onPrimaryFixedVariant = MochaCrust, + secondaryFixed = MochaBlue, + secondaryFixedDim = MochaBlue, + onSecondaryFixed = MochaCrust, + onSecondaryFixedVariant = MochaCrust, + tertiaryFixed = MochaPink, + tertiaryFixedDim = MochaPink, + onTertiaryFixed = MochaCrust, + onTertiaryFixedVariant = MochaCrust, surfaceContainerLowest = MochaCrust, surfaceContainerLow = MochaMantle, surfaceContainer = MochaBase, @@ -81,7 +103,7 @@ val CatppuccinTheme = inverseSurface = MochaText, inverseOnSurface = MochaBase, inversePrimary = LatteMauve, - outline = MochaOverlay0, + outline = MochaSubtext0, outlineVariant = MochaSurface1, scrim = MochaCrust, status = @@ -105,48 +127,62 @@ val CatppuccinTheme = PaletteColors( primary = LatteMauve, onPrimary = LatteBase, - primaryContainer = LattePrimaryContainer, - onPrimaryContainer = LatteOnPrimaryContainer, + primaryContainer = LatteBase, + onPrimaryContainer = LatteMauve, secondary = LatteBlue, - onSecondary = LatteBase, + onSecondary = LatteOnBlue, secondaryContainer = LatteMantle, onSecondaryContainer = LatteText, tertiary = LattePink, - onTertiary = LatteBase, - tertiaryContainer = LatteSurface1, + onTertiary = LatteDeepInk, + tertiaryContainer = LatteSurface0, onTertiaryContainer = LatteText, background = LatteBase, onBackground = LatteText, surface = LatteBase, onSurface = LatteText, surfaceVariant = LatteSurface0, - onSurfaceVariant = LatteSubtext0, + onSurfaceVariant = LatteText, + surfaceDim = LatteSurface0, + surfaceBright = LatteBase, + primaryFixed = MochaMauve, + primaryFixedDim = MochaMauve, + onPrimaryFixed = MochaCrust, + onPrimaryFixedVariant = MochaCrust, + secondaryFixed = MochaBlue, + secondaryFixedDim = MochaBlue, + onSecondaryFixed = MochaCrust, + onSecondaryFixedVariant = MochaCrust, + tertiaryFixed = MochaPink, + tertiaryFixedDim = MochaPink, + onTertiaryFixed = MochaCrust, + onTertiaryFixedVariant = MochaCrust, surfaceContainerLowest = LatteCrust, surfaceContainerLow = LatteMantle, surfaceContainer = LatteBase, surfaceContainerHigh = LatteSurface0, - surfaceContainerHighest = LatteSurface1, + surfaceContainerHighest = LatteSurface0, inverseSurface = LatteText, inverseOnSurface = LatteBase, inversePrimary = MochaMauve, - outline = LatteOverlay0, + outline = LatteSubtext0, outlineVariant = LatteSurface1, scrim = LatteCrust, status = HermesStatusColors( success = LatteGreen, successContainer = LatteSurface0, - onSuccess = LatteBase, + onSuccess = LatteDeepInk, warning = LatteYellow, warningContainer = LatteSurface0, - onWarning = LatteBase, + onWarning = LatteDeepInk, error = LatteRed, errorContainer = LatteSurface0, onError = LatteBase, - onErrorContainer = LatteRed, + onErrorContainer = LatteText, info = LatteBlue, infoContainer = LatteSurface0, - onInfo = LatteBase, + onInfo = LatteOnBlue, ), ), ) 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 fcb7f67c..6fc42111 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 @@ -73,6 +73,20 @@ val DefaultTheme = onSurface = SlateDarkFg1, surfaceVariant = SlateDarkBg1, onSurfaceVariant = SlateDarkFg3, + surfaceDim = SlateDarkBg0, + surfaceBright = SlateDarkBg4, + primaryFixed = SlateBrightTeal, + primaryFixedDim = SlateBrightTeal, + onPrimaryFixed = SlateDarkBg0, + onPrimaryFixedVariant = SlateDarkBg0, + secondaryFixed = SlateBrightCyan, + secondaryFixedDim = SlateBrightCyan, + onSecondaryFixed = SlateDarkBg0, + onSecondaryFixedVariant = SlateDarkBg0, + tertiaryFixed = SlateBrightBlue, + tertiaryFixedDim = SlateBrightBlue, + onTertiaryFixed = SlateDarkBg0, + onTertiaryFixedVariant = SlateDarkBg0, surfaceContainerLowest = SlateDarkBg0, surfaceContainerLow = SlateDarkBg1, surfaceContainer = SlateDarkBg2, @@ -121,6 +135,20 @@ val DefaultTheme = onSurface = SlateLightFg1, surfaceVariant = SlateLightBg1, onSurfaceVariant = SlateLightFg3, + surfaceDim = SlateLightBg4, + surfaceBright = SlateLightBg0, + primaryFixed = SlateBrightTeal, + primaryFixedDim = SlateBrightTeal, + onPrimaryFixed = SlateDarkBg0, + onPrimaryFixedVariant = SlateDarkBg0, + secondaryFixed = SlateBrightCyan, + secondaryFixedDim = SlateBrightCyan, + onSecondaryFixed = SlateDarkBg0, + onSecondaryFixedVariant = SlateDarkBg0, + tertiaryFixed = SlateBrightBlue, + tertiaryFixedDim = SlateBrightBlue, + onTertiaryFixed = SlateDarkBg0, + onTertiaryFixedVariant = SlateDarkBg0, surfaceContainerLowest = SlateLightBg0, surfaceContainerLow = SlateLightBg1, surfaceContainer = SlateLightBg2, @@ -129,7 +157,7 @@ val DefaultTheme = inverseSurface = SlateLightFg1, inverseOnSurface = SlateLightBg0, inversePrimary = SlateBrightTeal, - outline = SlateGray, + outline = SlateLightFg3, outlineVariant = SlateLightBg3, scrim = SlateLightBg0, status = 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 f56965f6..36fca5a4 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 @@ -9,18 +9,20 @@ import com.m57.hermescontrol.theme.buildTheme // Gruvbox Dark (medium contrast) — named swatches // --------------------------------------------------------------------- +// Official hard-contrast bg0; medium bg0/red only reaches 4.29:1. +private val GruvboxDarkInk = Color(0xFF1D2021) 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 // "Bright" accents — the variants gruvbox uses against a dark background. -private val GruvboxBrightRed = Color(0xFFFB4934) +// Derived role, not an upstream swatch: bright red/bg0 is only 4.29:1. +// Lighter red reaches 4.92:1 for error text and field labels on bg0. +private val GruvboxErrorText = Color(0xFFFF5F4F) private val GruvboxBrightGreen = Color(0xFFB8BB26) private val GruvboxBrightYellow = Color(0xFFFABD2F) private val GruvboxBrightBlue = Color(0xFF83A598) @@ -35,7 +37,6 @@ 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) @@ -43,28 +44,38 @@ 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) + +// Derived roles, not upstream Gruvbox swatches: light bg0/faded green is 4.29:1; +// white/green reaches 4.86:1. Hard dark bg0/faded yellow is below 4.5:1; +// deeper neutral ink/yellow reaches 4.97:1. +private val GruvboxOnFadedGreen = Color(0xFFFFFFFF) +private val GruvboxOnFadedYellow = Color(0xFF121212) 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. */ +// Regression theme-contract: fixed roles reuse bright accents in both modes; +// Fixed/Dim share a swatch. Containers stop at bg3 for >= 4.5:1 body text. +// Upstream: https://github.com/morhetz/gruvbox/blob/master/colors/gruvbox.vim + +/** Gruvbox theme — bright accents on dark bg0–bg3, faded accents on light bg0–bg3. */ val GruvboxTheme = buildTheme( dark = PaletteColors( primary = GruvboxBrightOrange, onPrimary = GruvboxDarkBg0, - primaryContainer = GruvboxDarkBg4, + primaryContainer = GruvboxDarkBg2, onPrimaryContainer = GruvboxDarkFg1, secondary = GruvboxBrightBlue, onSecondary = GruvboxDarkBg0, - secondaryContainer = GruvboxDarkBg3, + secondaryContainer = GruvboxDarkBg2, onSecondaryContainer = GruvboxDarkFg2, tertiary = GruvboxBrightPurple, onTertiary = GruvboxDarkBg0, - tertiaryContainer = GruvboxDarkBg4, + tertiaryContainer = GruvboxDarkBg2, onTertiaryContainer = GruvboxDarkFg1, background = GruvboxDarkBg0, onBackground = GruvboxDarkFg1, @@ -72,15 +83,29 @@ val GruvboxTheme = onSurface = GruvboxDarkFg1, surfaceVariant = GruvboxDarkBg1, onSurfaceVariant = GruvboxDarkFg3, + surfaceDim = GruvboxDarkBg0, + surfaceBright = GruvboxDarkBg3, + primaryFixed = GruvboxBrightOrange, + primaryFixedDim = GruvboxBrightOrange, + onPrimaryFixed = GruvboxDarkBg0, + onPrimaryFixedVariant = GruvboxDarkBg0, + secondaryFixed = GruvboxBrightBlue, + secondaryFixedDim = GruvboxBrightBlue, + onSecondaryFixed = GruvboxDarkBg0, + onSecondaryFixedVariant = GruvboxDarkBg0, + tertiaryFixed = GruvboxBrightPurple, + tertiaryFixedDim = GruvboxBrightPurple, + onTertiaryFixed = GruvboxDarkBg0, + onTertiaryFixedVariant = GruvboxDarkBg0, surfaceContainerLowest = GruvboxDarkBg0, surfaceContainerLow = GruvboxDarkBg1, surfaceContainer = GruvboxDarkBg2, surfaceContainerHigh = GruvboxDarkBg3, - surfaceContainerHighest = GruvboxDarkBg4, + surfaceContainerHighest = GruvboxDarkBg3, inverseSurface = GruvboxDarkFg1, inverseOnSurface = GruvboxDarkBg0, inversePrimary = GruvboxFadedOrange, - outline = GruvboxGray, + outline = GruvboxDarkFg2, outlineVariant = GruvboxDarkBg3, scrim = GruvboxDarkBg0, status = @@ -91,9 +116,9 @@ val GruvboxTheme = warning = GruvboxBrightYellow, warningContainer = GruvboxDarkBg3, onWarning = GruvboxDarkBg0, - error = GruvboxBrightRed, + error = GruvboxErrorText, errorContainer = GruvboxDarkBg3, - onError = GruvboxDarkBg0, + onError = GruvboxDarkInk, onErrorContainer = GruvboxDarkFg1, info = GruvboxBrightBlue, infoContainer = GruvboxDarkBg3, @@ -104,15 +129,15 @@ val GruvboxTheme = PaletteColors( primary = GruvboxFadedOrange, onPrimary = GruvboxLightBg0, - primaryContainer = GruvboxLightBg4, + primaryContainer = GruvboxLightBg2, onPrimaryContainer = GruvboxLightFg1, secondary = GruvboxFadedBlue, onSecondary = GruvboxLightBg0, - secondaryContainer = GruvboxLightBg3, + secondaryContainer = GruvboxLightBg2, onSecondaryContainer = GruvboxLightFg2, tertiary = GruvboxFadedPurple, onTertiary = GruvboxLightBg0, - tertiaryContainer = GruvboxLightBg4, + tertiaryContainer = GruvboxLightBg2, onTertiaryContainer = GruvboxLightFg1, background = GruvboxLightBg0, onBackground = GruvboxLightFg1, @@ -120,29 +145,43 @@ val GruvboxTheme = onSurface = GruvboxLightFg1, surfaceVariant = GruvboxLightBg1, onSurfaceVariant = GruvboxLightFg3, + surfaceDim = GruvboxLightBg3, + surfaceBright = GruvboxLightBg0, + primaryFixed = GruvboxBrightOrange, + primaryFixedDim = GruvboxBrightOrange, + onPrimaryFixed = GruvboxDarkBg0, + onPrimaryFixedVariant = GruvboxDarkBg0, + secondaryFixed = GruvboxBrightBlue, + secondaryFixedDim = GruvboxBrightBlue, + onSecondaryFixed = GruvboxDarkBg0, + onSecondaryFixedVariant = GruvboxDarkBg0, + tertiaryFixed = GruvboxBrightPurple, + tertiaryFixedDim = GruvboxBrightPurple, + onTertiaryFixed = GruvboxDarkBg0, + onTertiaryFixedVariant = GruvboxDarkBg0, surfaceContainerLowest = GruvboxLightBg0, surfaceContainerLow = GruvboxLightBg1, surfaceContainer = GruvboxLightBg2, surfaceContainerHigh = GruvboxLightBg3, - surfaceContainerHighest = GruvboxLightBg4, + surfaceContainerHighest = GruvboxLightBg3, inverseSurface = GruvboxLightFg1, inverseOnSurface = GruvboxLightBg0, inversePrimary = GruvboxBrightOrange, - outline = GruvboxGray, + outline = GruvboxLightFg2, outlineVariant = GruvboxLightBg3, scrim = GruvboxLightBg0, status = HermesStatusColors( success = GruvboxFadedGreen, successContainer = GruvboxLightBg3, - onSuccess = GruvboxLightBg0, + onSuccess = GruvboxOnFadedGreen, warning = GruvboxFadedYellow, warningContainer = GruvboxLightBg3, - onWarning = GruvboxLightBg0, + onWarning = GruvboxOnFadedYellow, error = GruvboxFadedRed, errorContainer = GruvboxLightBg3, onError = GruvboxLightBg0, - onErrorContainer = GruvboxFadedRed, + onErrorContainer = GruvboxLightFg1, 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 f4e1d13f..866a851b 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 @@ -32,7 +32,6 @@ private val MonoLightBg = Color(0xFFFAFAFA) // background, surface, containerLow 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 @@ -71,6 +70,20 @@ val MonochromeTheme = onSurface = MonoDarkTextBright, surfaceVariant = MonoDarkContainerHigh, onSurfaceVariant = MonoDarkTextDim, + surfaceDim = MonoDarkBg, + surfaceBright = MonoDarkContainerHighest, + primaryFixed = MonoDarkTextBright, + primaryFixedDim = MonoDarkTextBright, + onPrimaryFixed = MonoDarkBg, + onPrimaryFixedVariant = MonoDarkBg, + secondaryFixed = MonoDarkSecondary, + secondaryFixedDim = MonoDarkSecondary, + onSecondaryFixed = MonoDarkBg, + onSecondaryFixedVariant = MonoDarkBg, + tertiaryFixed = MonoDarkTertiary, + tertiaryFixedDim = MonoDarkTertiary, + onTertiaryFixed = MonoDarkBg, + onTertiaryFixedVariant = MonoDarkBg, surfaceContainerLowest = MonoDarkFloor, surfaceContainerLow = MonoDarkBg, surfaceContainer = MonoDarkContainer, @@ -112,13 +125,27 @@ val MonochromeTheme = tertiary = MonoLightTertiary, onTertiary = MonoLightBg, tertiaryContainer = MonoLightContainerHigh, - onTertiaryContainer = MonoLightTertiary, + onTertiaryContainer = MonoLightSecondary, background = MonoLightBg, onBackground = MonoLightTextBright, surface = MonoLightBg, onSurface = MonoLightTextBright, surfaceVariant = MonoLightContainerHigh, onSurfaceVariant = MonoLightTextDim, + surfaceDim = MonoLightContainerHighest, + surfaceBright = MonoLightBg, + primaryFixed = MonoDarkTextBright, + primaryFixedDim = MonoDarkTextBright, + onPrimaryFixed = MonoDarkBg, + onPrimaryFixedVariant = MonoDarkBg, + secondaryFixed = MonoDarkSecondary, + secondaryFixedDim = MonoDarkSecondary, + onSecondaryFixed = MonoDarkBg, + onSecondaryFixedVariant = MonoDarkBg, + tertiaryFixed = MonoDarkTertiary, + tertiaryFixedDim = MonoDarkTertiary, + onTertiaryFixed = MonoDarkBg, + onTertiaryFixedVariant = MonoDarkBg, surfaceContainerLowest = MonoLightCeiling, surfaceContainerLow = MonoLightBg, surfaceContainer = MonoLightContainer, @@ -127,7 +154,7 @@ val MonochromeTheme = inverseSurface = MonoLightTextBright, inverseOnSurface = MonoLightBg, inversePrimary = MonoDarkTextBright, - outline = MonoLightOutline, + outline = MonoLightTertiary, outlineVariant = MonoLightOutlineVariant, scrim = Color.Black, status = @@ -144,7 +171,7 @@ val MonochromeTheme = onErrorContainer = MonoLightTextBright, info = MonoLightInfo, infoContainer = MonoLightContainerHigh, - onInfo = MonoLightBg, + onInfo = MonoLightTextBright, ), ), ) 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 index 599bdda1..b702227e 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/presets/NordScheme.kt @@ -11,9 +11,12 @@ import com.m57.hermescontrol.theme.buildTheme // 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. +// are reused for fixed roles; primary/error need documented accessible tones. // --------------------------------------------------------------------- +// Derived role, not an upstream Nord swatch: nord0/red is 3.05:1 and +// nord0/purple is 4.41:1. Darker Polar Night ink meets 4.5:1 on both. +private val NordPolarInk = Color(0xFF0F1218) private val Nord0 = Color(0xFF2E3440) // Polar Night — darkest private val Nord1 = Color(0xFF3B4252) private val Nord2 = Color(0xFF434C5E) @@ -23,11 +26,23 @@ 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 + +// Derived roles, not upstream Nord swatches. Nord10/Snow Storm is only 3.50:1; +// deeper Frost ink reaches 4.92:1 on nord6 for primary text/focus labels. +private val NordFrostInk = Color(0xFF486991) + +// Official Aurora red/nord0 is 3.05:1; a brighter red reaches 4.67:1. +private val NordErrorBright = Color(0xFFDE858F) + +// Official Aurora red/nord6 is 3.55:1; deeper red reaches 5.69:1. +private val NordErrorInk = Color(0xFFA53547) private val Nord13 = Color(0xFFEBCB8B) // Aurora yellow — warning private val Nord14 = Color(0xFFA3BE8C) // Aurora green — success private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary +// Fixed/Dim reuse each official accent in both modes (no invented tonal ladder). +// Upstream: https://www.nordtheme.com/docs/colors-and-palettes/ + /** * Nord theme. * @@ -36,12 +51,11 @@ private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary * 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 + * Pastel Frost/Aurora fills use dark ink 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. + * variant. Light primary/error use Snow Storm ink on their darker fills. */ val NordTheme = buildTheme( @@ -56,7 +70,7 @@ val NordTheme = secondaryContainer = Nord2, onSecondaryContainer = Nord4, tertiary = Nord15, - onTertiary = Nord0, + onTertiary = NordPolarInk, tertiaryContainer = Nord3, onTertiaryContainer = Nord6, background = Nord0, @@ -65,6 +79,20 @@ val NordTheme = onSurface = Nord6, surfaceVariant = Nord1, onSurfaceVariant = Nord4, + surfaceDim = Nord0, + surfaceBright = Nord3, + primaryFixed = Nord8, + primaryFixedDim = Nord8, + onPrimaryFixed = Nord0, + onPrimaryFixedVariant = Nord0, + secondaryFixed = Nord9, + secondaryFixedDim = Nord9, + onSecondaryFixed = Nord0, + onSecondaryFixedVariant = Nord0, + tertiaryFixed = Nord15, + tertiaryFixedDim = Nord15, + onTertiaryFixed = NordPolarInk, + onTertiaryFixedVariant = NordPolarInk, surfaceContainerLowest = Nord0, surfaceContainerLow = Nord1, surfaceContainer = Nord2, @@ -72,8 +100,8 @@ val NordTheme = surfaceContainerHighest = Nord3, inverseSurface = Nord6, inverseOnSurface = Nord0, - inversePrimary = Nord8, - outline = Nord3, + inversePrimary = NordFrostInk, + outline = Nord4, outlineVariant = Nord2, scrim = Nord0, status = @@ -84,9 +112,9 @@ val NordTheme = warning = Nord13, warningContainer = Nord2, onWarning = Nord0, - error = Nord11, + error = NordErrorBright, errorContainer = Nord2, - onError = Nord0, + onError = NordPolarInk, onErrorContainer = Nord6, info = Nord9, infoContainer = Nord2, @@ -95,8 +123,8 @@ val NordTheme = ), light = PaletteColors( - primary = Nord8, - onPrimary = Nord0, + primary = NordFrostInk, + onPrimary = Nord6, primaryContainer = Nord4, onPrimaryContainer = Nord0, secondary = Nord9, @@ -104,7 +132,7 @@ val NordTheme = secondaryContainer = Nord5, onSecondaryContainer = Nord1, tertiary = Nord15, - onTertiary = Nord0, + onTertiary = NordPolarInk, tertiaryContainer = Nord4, onTertiaryContainer = Nord0, background = Nord6, @@ -113,6 +141,20 @@ val NordTheme = onSurface = Nord0, surfaceVariant = Nord5, onSurfaceVariant = Nord2, + surfaceDim = Nord4, + surfaceBright = Nord6, + primaryFixed = Nord8, + primaryFixedDim = Nord8, + onPrimaryFixed = Nord0, + onPrimaryFixedVariant = Nord0, + secondaryFixed = Nord9, + secondaryFixedDim = Nord9, + onSecondaryFixed = Nord0, + onSecondaryFixedVariant = Nord0, + tertiaryFixed = Nord15, + tertiaryFixedDim = Nord15, + onTertiaryFixed = NordPolarInk, + onTertiaryFixedVariant = NordPolarInk, surfaceContainerLowest = Nord6, surfaceContainerLow = Nord5, surfaceContainer = Nord5, @@ -132,10 +174,10 @@ val NordTheme = warning = Nord13, warningContainer = Nord5, onWarning = Nord0, - error = Nord11, + error = NordErrorInk, errorContainer = Nord5, - onError = Nord0, - onErrorContainer = Nord11, + onError = Nord6, + onErrorContainer = Nord0, info = Nord9, infoContainer = Nord5, onInfo = Nord0, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt index 5648a40a..0d18f6c0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/SharedComponents.kt @@ -40,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.theme.HermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing @@ -81,28 +82,8 @@ fun StatusBadge( val spacing = LocalSpacing.current val statusColors = LocalHermesStatusColors.current val (bgColor, fgColor) = - when (status) { - StatusBadgeType.SUCCESS -> { - statusColors.successContainer to statusColors.success - } - - StatusBadgeType.WARNING -> { - statusColors.warningContainer to statusColors.warning - } - - StatusBadgeType.ERROR -> { - statusColors.errorContainer to statusColors.error - } - - StatusBadgeType.INFO -> { - statusColors.infoContainer to statusColors.info - } - - StatusBadgeType.NEUTRAL -> { - MaterialTheme.colorScheme.surfaceContainerHigh to - MaterialTheme.colorScheme.onSurfaceVariant - } - } + statusBadgeColors(status, statusColors) + ?: (MaterialTheme.colorScheme.surfaceContainerHigh to MaterialTheme.colorScheme.onSurfaceVariant) Surface( modifier = modifier, shape = RoundedCornerShape(8.dp), @@ -135,6 +116,20 @@ fun StatusBadge( enum class StatusBadgeType { SUCCESS, WARNING, ERROR, INFO, NEUTRAL } +/** Exact fill/text pair shared by status badges and live-session indicators. */ +internal fun statusBadgeColors( + status: StatusBadgeType, + colors: HermesStatusColors, +): Pair? = + // PR #1417: on-status tokens belong on the status fill, not its container. + when (status) { + StatusBadgeType.SUCCESS -> colors.success to colors.onSuccess + StatusBadgeType.WARNING -> colors.warning to colors.onWarning + StatusBadgeType.ERROR -> colors.error to colors.onError + StatusBadgeType.INFO -> colors.info to colors.onInfo + StatusBadgeType.NEUTRAL -> null // Neutral badges use the active Material scheme. + } + // ── ToggleRow — switch row with label + description ──────────────────── @Composable diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt index 112493f2..7fad6475 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionLiveStatusIndicator.kt @@ -25,6 +25,8 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.SessionLiveStatus import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalSpacing +import com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.statusBadgeColors @Composable fun SessionLiveStatusIndicator( @@ -35,27 +37,26 @@ fun SessionLiveStatusIndicator( val spacing = LocalSpacing.current val statusColors = LocalHermesStatusColors.current - val (bgColor, fgColor, textRes, descRes) = + val (badgeType, textRes, descRes) = when (liveStatus) { SessionLiveStatus.WORKING -> { - Tuple4( - statusColors.successContainer, - statusColors.success, + Triple( + StatusBadgeType.SUCCESS, R.string.sessions_live_status_running, R.string.sessions_live_status_running_desc, ) } SessionLiveStatus.WAITING -> { - Tuple4( - statusColors.warningContainer, - statusColors.warning, + Triple( + StatusBadgeType.WARNING, R.string.sessions_live_status_waiting, R.string.sessions_live_status_waiting_desc, ) } } + val (bgColor, fgColor) = requireNotNull(statusBadgeColors(badgeType, statusColors)) val text = stringResource(textRes) val description = stringResource(descRes) @@ -103,10 +104,3 @@ fun SessionLiveStatusIndicator( } } } - -private data class Tuple4( - val first: A, - val second: B, - val third: C, - val fourth: D, -) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt index 1a8207f6..d17689db 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AboutSection.kt @@ -14,7 +14,6 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -270,11 +269,6 @@ private fun ReleaseCandidateRow( checked = checked, onCheckedChange = onCheckedChange, enabled = enabled, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } } 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 e59f37a6..a3d9c37d 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 @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.settings.components +import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -21,7 +22,6 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider @@ -41,6 +41,7 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.BusySendMode import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset +import com.m57.hermescontrol.theme.ThemeRegistry import com.m57.hermescontrol.ui.chat.label import com.m57.hermescontrol.ui.settings.SectionCard import kotlin.math.abs @@ -55,6 +56,7 @@ internal fun AppearanceSection( themePreset: ThemePreset, onThemePresetChange: (ThemePreset) -> Unit, ) { + val dynamicAvailable = useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S SectionCard { Text( text = stringResource(R.string.settings_item_theme), @@ -106,11 +108,6 @@ internal fun AppearanceSection( Switch( checked = useDynamicColors, onCheckedChange = onUseDynamicColorsChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } @@ -122,72 +119,30 @@ internal fun AppearanceSection( ) Spacer(modifier = Modifier.height(8.dp)) + if (dynamicAvailable) { + Text( + text = stringResource(R.string.settings_desc_theme_preset_dynamic), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + var presetsExpanded by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxWidth()) { OutlinedButton( onClick = { presetsExpanded = true }, - enabled = !useDynamicColors, modifier = Modifier.fillMaxWidth(), ) { - Text( - when (themePreset) { - ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default) - ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome) - 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.NORD -> stringResource(R.string.theme_preset_nord) - }, - ) + Text(themePreset.label()) } DropdownMenu( expanded = presetsExpanded, onDismissRequest = { presetsExpanded = false }, modifier = Modifier.fillMaxWidth(0.85f), ) { - ThemePreset.entries.forEach { preset -> + ThemeRegistry.forEach { (preset, _) -> DropdownMenuItem( - text = { - Text( - when (preset) { - ThemePreset.DEFAULT -> { - stringResource( - R.string.theme_preset_default, - ) - } - - ThemePreset.MONOCHROME -> { - stringResource( - R.string.theme_preset_monochrome, - ) - } - - 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.NORD -> { - stringResource( - R.string.theme_preset_nord, - ) - } - }, - ) - }, + text = { Text(preset.label()) }, onClick = { onThemePresetChange(preset) presetsExpanded = false @@ -199,6 +154,19 @@ internal fun AppearanceSection( } } +@Composable +private fun ThemePreset.label(): String = + stringResource( + when (this) { + ThemePreset.DEFAULT -> R.string.theme_preset_default + ThemePreset.MONOCHROME -> R.string.theme_preset_monochrome + ThemePreset.GRUVBOX -> R.string.theme_preset_gruvbox + ThemePreset.CATPPUCCIN -> R.string.theme_preset_catppuccin + ThemePreset.AMOLED -> R.string.theme_preset_amoled + ThemePreset.NORD -> R.string.theme_preset_nord + }, + ) + @Composable internal fun ChatSection( typingEffectEnabled: Boolean, @@ -485,11 +453,6 @@ internal fun ChatSection( checked = showModelProvider, onCheckedChange = onShowModelProviderChange, modifier = Modifier.testTag("settings_show_model_provider"), - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } @@ -516,11 +479,6 @@ internal fun ChatSection( Switch( checked = typingEffectEnabled, onCheckedChange = onTypingEffectEnabledChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt index 7eca2657..fe8b38b0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/BehaviorSection.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -50,11 +49,6 @@ internal fun BehaviorSection( Switch( checked = autoReconnect, onCheckedChange = onAutoReconnectChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } @@ -83,11 +77,6 @@ internal fun BehaviorSection( Switch( checked = keepConnectedInBackground, onCheckedChange = onKeepConnectedInBackgroundChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } @@ -116,11 +105,6 @@ internal fun BehaviorSection( Switch( checked = restoreLastSession, onCheckedChange = onRestoreLastSessionChange, - colors = - SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primaryContainer, - ), ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00ed6cd8..f5916aa7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1845,4 +1845,5 @@ Catalog No catalog entries Installed + Used for status colors while Material You supplies the app colors. diff --git a/app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt b/app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt new file mode 100644 index 00000000..c2e5568c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt @@ -0,0 +1,64 @@ +package com.m57.hermescontrol.theme + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Enabled-state contract for the pinned Material 3 defaults, verified again by the device gallery. */ +class ThemeComponentContrastTest { + @Test + fun everyResolvedPresetSupportsEnabledComponentStates() { + ThemeRegistry.forEach { (preset, _) -> + listOf(true, false).forEach { dark -> + val c = resolveColorScheme(preset, dark) + // PR #1417: these are component-state combinations, not just on-color pairs. + val graphics = + listOf( + "switch on thumb/track" to (c.onPrimary to c.primary), + "switch on track/surface" to (c.primary to c.surface), + "switch off thumb/track" to (c.outline to c.surfaceContainerHighest), + "switch off border/surface" to (c.outline to c.surface), + "radio selected/surface" to (c.primary to c.surface), + "radio unselected/surface" to (c.onSurfaceVariant to c.surface), + "checkbox selected/surface" to (c.primary to c.surface), + "checkbox checkmark/box" to (c.onPrimary to c.primary), + "checkbox unselected/surface" to (c.onSurfaceVariant to c.surface), + "slider active/inactive" to (c.primary to c.secondaryContainer), + "field normal border/surface" to (c.outline to c.surface), + "field focused border/surface" to (c.primary to c.surface), + "field error border/surface" to (c.error to c.surface), + "segment selected border/container" to (c.outline to c.secondaryContainer), + "segment unselected border/surface" to (c.outline to c.surface), + ) + val text = + listOf( + "primary foreground/surface" to (c.primary to c.surface), + "primary foreground/background" to (c.primary to c.background), + "error foreground/surface" to (c.error to c.surface), + "error foreground/background" to (c.error to c.background), + "field normal label/surface" to (c.onSurfaceVariant to c.surface), + "field focused label/surface" to (c.primary to c.surface), + "field error label/surface" to (c.error to c.surface), + "segment selected content/container" to (c.onSecondaryContainer to c.secondaryContainer), + "segment unselected content/surface" to (c.onSurface to c.surface), + ) + graphics.forEach { (name, pair) -> checkPair(preset, dark, name, pair, 3f) } + text.forEach { (name, pair) -> checkPair(preset, dark, name, pair, 4.5f) } + } + } + } + + private fun checkPair( + preset: ThemePreset, + dark: Boolean, + name: String, + pair: Pair, + minimum: Float, + ) { + val a = pair.first.luminance() + val b = pair.second.luminance() + val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f) + assertTrue("$preset dark=$dark $name is $ratio, expected >= $minimum", ratio >= minimum) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt b/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt index 2228a910..e85dc2d9 100644 --- a/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt @@ -1,67 +1,164 @@ package com.m57.hermescontrol.theme +import androidx.compose.material3.ColorScheme 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 com.m57.hermescontrol.ui.common.StatusBadgeType +import com.m57.hermescontrol.ui.common.statusBadgeColors import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test +import java.lang.reflect.Modifier /** * Guards the palette template's color invariants: - * 1. Every shipped error slot pair meets >= 3:1 WCAG contrast. + * 1. Normal-text pairs meet >= 4.5:1 contrast in every resolved mode. * 2. ThemeMode combinations are validated at construction time. - * 3. One-mode themes resolve and fall back to the default theme correctly. + * 3. Every Material role is explicitly mapped; fixed roles are mode-independent. + * 4. 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 registryCoversEveryPresetExactlyOnce() { + assertEquals(ThemePreset.entries, ThemeRegistry.map { it.preset }) + } @Test - fun errorSlotPairsMeetContrastInEveryShippedMode() { - themes.forEach { (preset, theme) -> + fun textSlotPairsMeetContrastInEveryResolvedMode() { + ThemeRegistry.forEach { (preset, _) -> 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, - ) + val c = resolveColorScheme(preset, dark) + val pairs = + listOf( + "onPrimary/primary" to (c.onPrimary to c.primary), + "onPrimaryContainer/primaryContainer" to (c.onPrimaryContainer to c.primaryContainer), + "onSecondary/secondary" to (c.onSecondary to c.secondary), + "onSecondaryContainer/secondaryContainer" to (c.onSecondaryContainer to c.secondaryContainer), + "onTertiary/tertiary" to (c.onTertiary to c.tertiary), + "onTertiaryContainer/tertiaryContainer" to (c.onTertiaryContainer to c.tertiaryContainer), + "onBackground/background" to (c.onBackground to c.background), + "onSurface/surface" to (c.onSurface to c.surface), + "onSurfaceVariant/surfaceVariant" to (c.onSurfaceVariant to c.surfaceVariant), + "onSurface/surfaceDim" to (c.onSurface to c.surfaceDim), + "onSurface/surfaceBright" to (c.onSurface to c.surfaceBright), + "onSurface/surfaceContainerLowest" to (c.onSurface to c.surfaceContainerLowest), + "onSurface/surfaceContainerLow" to (c.onSurface to c.surfaceContainerLow), + "onSurface/surfaceContainer" to (c.onSurface to c.surfaceContainer), + "onSurface/surfaceContainerHigh" to (c.onSurface to c.surfaceContainerHigh), + "onSurface/surfaceContainerHighest" to (c.onSurface to c.surfaceContainerHighest), + "inverseOnSurface/inverseSurface" to (c.inverseOnSurface to c.inverseSurface), + "onError/error" to (c.onError to c.error), + "onErrorContainer/errorContainer" to (c.onErrorContainer to c.errorContainer), + "onPrimaryFixed/primaryFixed" to (c.onPrimaryFixed to c.primaryFixed), + "onPrimaryFixedVariant/primaryFixed" to (c.onPrimaryFixedVariant to c.primaryFixed), + "onPrimaryFixed/primaryFixedDim" to (c.onPrimaryFixed to c.primaryFixedDim), + "onPrimaryFixedVariant/primaryFixedDim" to (c.onPrimaryFixedVariant to c.primaryFixedDim), + "onSecondaryFixed/secondaryFixed" to (c.onSecondaryFixed to c.secondaryFixed), + "onSecondaryFixedVariant/secondaryFixed" to (c.onSecondaryFixedVariant to c.secondaryFixed), + "onSecondaryFixed/secondaryFixedDim" to (c.onSecondaryFixed to c.secondaryFixedDim), + "onSecondaryFixedVariant/secondaryFixedDim" to + (c.onSecondaryFixedVariant to c.secondaryFixedDim), + "onTertiaryFixed/tertiaryFixed" to (c.onTertiaryFixed to c.tertiaryFixed), + "onTertiaryFixedVariant/tertiaryFixed" to (c.onTertiaryFixedVariant to c.tertiaryFixed), + "onTertiaryFixed/tertiaryFixedDim" to (c.onTertiaryFixed to c.tertiaryFixedDim), + "onTertiaryFixedVariant/tertiaryFixedDim" to (c.onTertiaryFixedVariant to c.tertiaryFixedDim), + ) + pairs.forEach { (name, colors) -> + val ratio = contrast(colors.first, colors.second) + assertTrue("$preset dark=$dark $name contrast $ratio must be >= 4.5:1", ratio >= 4.5f) + } } } } + @Test + fun renderedStatusBadgePairsMeetContrastInEveryResolvedMode() { + ThemeRegistry.forEach { (preset, _) -> + listOf(true, false).forEach { dark -> + val colors = resolveStatusColors(preset, dark) + StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL }.forEach { type -> + // PR #1417: test the mapping consumed by both reusable badge renderers. + val (background, foreground) = requireNotNull(statusBadgeColors(type, colors)) + val ratio = contrast(foreground, background) + assertTrue("$preset dark=$dark $type badge contrast $ratio must be >= 4.5:1", ratio >= 4.5f) + } + } + } + } + + @Test + fun statusBadgesUseMatchingStatusFillsAndOnColors() { + val c = dummyColors().status + assertEquals(c.success to c.onSuccess, statusBadgeColors(StatusBadgeType.SUCCESS, c)) + assertEquals(c.warning to c.onWarning, statusBadgeColors(StatusBadgeType.WARNING, c)) + assertEquals(c.error to c.onError, statusBadgeColors(StatusBadgeType.ERROR, c)) + assertEquals(c.info to c.onInfo, statusBadgeColors(StatusBadgeType.INFO, c)) + } + + @Test + fun fixedRolesDoNotChangeBetweenShippedModes() { + ThemeRegistry.forEach { (preset, theme) -> + val dark = theme.darkScheme + val light = theme.lightScheme + if (dark != null && light != null) { + val darkRoles = colorFields(dark).filterKeys { "Fixed" in it } + val lightRoles = colorFields(light).filterKeys { "Fixed" in it } + assertEquals("$preset fixed roles must be mode-independent", darkRoles, lightRoles) + } + } + } + + @Test + fun everyMaterialColorRoleIsMappedFromThePalette() { + // Distinct sentinel values catch swapped roles and accidental Material defaults. + val colors = dummyColors() + val expected = colorFields(colors).toMutableMap() + expected["surfaceTint"] = expected.getValue("primary") + expected.putAll( + colorFields(colors.status).filterKeys { + it in + setOf( + "error", + "onError", + "errorContainer", + "onErrorContainer", + ) + }, + ) + val theme = buildTheme(colors, colors) + listOf(theme.darkScheme, theme.lightScheme).forEach { scheme -> + assertEquals(expected, colorFields(requireNotNull(scheme))) + } + } + + private fun colorFields(value: Any): Map = + value.javaClass.declaredFields + .filter { + it.type == java.lang.Long.TYPE && + !Modifier.isStatic(it.modifiers) + }.associate { field -> + field.isAccessible = true + field.name to field.getLong(value) + } + /** * Full-bleed chat renderer gate (issue #866): agent prose renders directly * on the screen background (HermesScaffold containerColor = background), * so the full-bleed text pairs must hold against [ColorScheme.background]: * - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text * - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI - * (The header deliberately avoids `primary`: Nord's light mode reuses its - * pastel Frost accent as primary, which cannot reach 3:1 on a light - * background.) - * Iterates ThemePreset.entries directly so EVERY shipped preset (incl. - * NORD, which the [themes] list above predates) is covered. + * Header text uses onSurfaceVariant independently of the theme's accent. + * The registry covers every shipped preset and its fallback mode. */ @Test fun fullBleedTextPairsMeetContrastInEveryShippedMode() { - ThemePreset.entries.forEach { preset -> + ThemeRegistry.forEach { (preset, _) -> listOf(true, false).forEach { dark -> - val scheme = resolveColorScheme(preset, darkTheme = dark) ?: return@forEach + val scheme = resolveColorScheme(preset, darkTheme = dark) assertTrue( "$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)", contrast(scheme.onSurface, scheme.background) >= 4.5f, @@ -84,18 +181,18 @@ class ThemePaletteTest { @Test fun everyPresetResolvesItsDeclaredModes() { - themes.forEach { (preset, theme) -> + ThemeRegistry.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, - ) - } + assertSame( + "$preset dark=$dark must resolve its palette or Default fallback", + theme.schemeFor(dark) ?: DefaultTheme.schemeFor(dark), + resolveColorScheme(preset, dark), + ) + assertSame( + "$preset dark=$dark must resolve its status or Default fallback", + theme.statusFor(dark) ?: DefaultTheme.statusFor(dark), + resolveStatusColors(preset, dark), + ) } } } @@ -169,50 +266,64 @@ class ThemePaletteTest { 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, + primary = Color(0xFF000000L + 1), + onPrimary = Color(0xFF000000L + 2), + primaryContainer = Color(0xFF000000L + 3), + onPrimaryContainer = Color(0xFF000000L + 4), + secondary = Color(0xFF000000L + 5), + onSecondary = Color(0xFF000000L + 6), + secondaryContainer = Color(0xFF000000L + 7), + onSecondaryContainer = Color(0xFF000000L + 8), + tertiary = Color(0xFF000000L + 9), + onTertiary = Color(0xFF000000L + 10), + tertiaryContainer = Color(0xFF000000L + 11), + onTertiaryContainer = Color(0xFF000000L + 12), + background = Color(0xFF000000L + 13), + onBackground = Color(0xFF000000L + 14), + surface = Color(0xFF000000L + 15), + onSurface = Color(0xFF000000L + 16), + surfaceVariant = Color(0xFF000000L + 17), + onSurfaceVariant = Color(0xFF000000L + 18), + surfaceDim = Color(0xFF000000L + 19), + surfaceBright = Color(0xFF000000L + 20), + primaryFixed = Color(0xFF000000L + 21), + primaryFixedDim = Color(0xFF000000L + 22), + onPrimaryFixed = Color(0xFF000000L + 23), + onPrimaryFixedVariant = Color(0xFF000000L + 24), + secondaryFixed = Color(0xFF000000L + 25), + secondaryFixedDim = Color(0xFF000000L + 26), + onSecondaryFixed = Color(0xFF000000L + 27), + onSecondaryFixedVariant = Color(0xFF000000L + 28), + tertiaryFixed = Color(0xFF000000L + 29), + tertiaryFixedDim = Color(0xFF000000L + 30), + onTertiaryFixed = Color(0xFF000000L + 31), + onTertiaryFixedVariant = Color(0xFF000000L + 32), + surfaceContainerLowest = Color(0xFF000000L + 33), + surfaceContainerLow = Color(0xFF000000L + 34), + surfaceContainer = Color(0xFF000000L + 35), + surfaceContainerHigh = Color(0xFF000000L + 36), + surfaceContainerHighest = Color(0xFF000000L + 37), + inverseSurface = Color(0xFF000000L + 38), + inverseOnSurface = Color(0xFF000000L + 39), + inversePrimary = Color(0xFF000000L + 40), + outline = Color(0xFF000000L + 41), + outlineVariant = Color(0xFF000000L + 42), + scrim = Color(0xFF000000L + 43), 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, + success = Color(0xFF000000L + 100), + successContainer = Color(0xFF000000L + 101), + onSuccess = Color(0xFF000000L + 102), + warning = Color(0xFF000000L + 103), + warningContainer = Color(0xFF000000L + 104), + onWarning = Color(0xFF000000L + 105), + error = Color(0xFF000000L + 106), + errorContainer = Color(0xFF000000L + 107), + onError = Color(0xFF000000L + 108), + onErrorContainer = Color(0xFF000000L + 109), + info = Color(0xFF000000L + 110), + infoContainer = Color(0xFF000000L + 111), + onInfo = Color(0xFF000000L + 112), ), ) }