fix(theme): complete color and component contrast contracts (#1417)

This commit is contained in:
m57 authored and GitHub committed 2026-09-30 21:14:56 +03:00
1 parent 9529a7e32c
commit 56bb3a88c2
22 files changed
+1013 -318

No files matched your search

+6 -3
View File
@@ -23,7 +23,7 @@ Android layout dimensions use `dp`; text sizes, line heights, and tracking use `
| Concern | Authoritative source | | 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) | | 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/) | | 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) | | 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. - **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) [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 guards normal-text slot pairs, full-bleed prose/header contrast, complete Material
invariants. Those tests are useful but do not establish contrast or accessibility 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. compliance for every rendered component, alpha blend, or dynamic palette.
Record device/emulator steps and observed results for changed UI; explicitly name Record device/emulator steps and observed results for changed UI; explicitly name
any gate that could not be verified. any gate that could not be verified.
@@ -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 }
},
)
}
}
@@ -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<Sample>()
@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<Array<Any>> =
ThemeRegistry.flatMap { (preset, _) -> listOf(true, false).map { arrayOf<Any>(preset, it) } }
}
}
@@ -10,8 +10,13 @@ import androidx.compose.ui.graphics.luminance
* Material You changes the *primary* / *surface* palette based on the user's * Material You changes the *primary* / *surface* palette based on the user's
* wallpaper, but success / warning / error / info colours must stay * wallpaper, but success / warning / error / info colours must stay
* semantically correct (green = good, red = bad) regardless of what the * semantically correct (green = good, red = bad) regardless of what the
* wallpaper-derived palette would choose. This struct is provided via * wallpaper-derived palette would choose.
* [LocalHermesStatusColors] and should be used for all status indicators, *
* `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. * badges, and semantic feedback colours.
*/ */
data class HermesStatusColors( data class HermesStatusColors(
@@ -28,6 +28,9 @@ enum class ThemeMode { FULL, DARK_ONLY, LIGHT_ONLY }
* *
* The Material error slots are derived from [status] — the theme author * The Material error slots are derived from [status] — the theme author
* defines semantic colors once and the scheme inherits them. * 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( data class PaletteColors(
val primary: Color, val primary: Color,
@@ -48,6 +51,20 @@ data class PaletteColors(
val onSurface: Color, val onSurface: Color,
val surfaceVariant: Color, val surfaceVariant: Color,
val onSurfaceVariant: 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 surfaceContainerLowest: Color,
val surfaceContainerLow: Color, val surfaceContainerLow: Color,
val surfaceContainer: Color, val surfaceContainer: Color,
@@ -123,6 +140,20 @@ private fun darkSchemeOf(c: PaletteColors): ColorScheme =
surfaceVariant = c.surfaceVariant, surfaceVariant = c.surfaceVariant,
onSurfaceVariant = c.onSurfaceVariant, onSurfaceVariant = c.onSurfaceVariant,
surfaceTint = c.primary, 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, surfaceContainerLowest = c.surfaceContainerLowest,
surfaceContainerLow = c.surfaceContainerLow, surfaceContainerLow = c.surfaceContainerLow,
surfaceContainer = c.surfaceContainer, surfaceContainer = c.surfaceContainer,
@@ -161,6 +192,20 @@ private fun lightSchemeOf(c: PaletteColors): ColorScheme =
surfaceVariant = c.surfaceVariant, surfaceVariant = c.surfaceVariant,
onSurfaceVariant = c.onSurfaceVariant, onSurfaceVariant = c.onSurfaceVariant,
surfaceTint = c.primary, 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, surfaceContainerLowest = c.surfaceContainerLowest,
surfaceContainerLow = c.surfaceContainerLow, surfaceContainerLow = c.surfaceContainerLow,
surfaceContainer = c.surfaceContainer, surfaceContainer = c.surfaceContainer,
@@ -12,7 +12,8 @@ All theme code is under:
``` ```
app/src/main/java/com/m57/hermescontrol/theme/ 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 ├── PaletteTemplate.kt # the template — the ONE shape every theme follows
├── Color.kt # shared tokens for non-theme code (status fallbacks, code blocks, …) ├── Color.kt # shared tokens for non-theme code (status fallbacks, code blocks, …)
├── HermesStatusColors.kt # semantic status color model (success/warning/error/info + on*) ├── 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 The template maps `PaletteColors` into the Material 3 `ColorScheme` slots. The
Material `error` slots (`error`, `onError`, `errorContainer`, `onErrorContainer`) Material `error` slots (`error`, `onError`, `errorContainer`, `onErrorContainer`)
are **derived** from the theme's status set — the theme author defines semantic 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 ## Preset conventions
@@ -69,8 +77,33 @@ colors once.
light text would fail contrast. light text would fail contrast.
- **Grayscale status colors** (Monochrome, AMOLED) separate success/warning/ - **Grayscale status colors** (Monochrome, AMOLED) separate success/warning/
error/info by lightness only — consumers must pair with icons or labels. 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 - Normal-text foreground/background pairs, including accent containers, fixed
`ThemePaletteTest` — a preset that breaks this fails the unit-test gate. 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 ## Theme modes
@@ -87,37 +120,33 @@ doesn't ship — never to a sibling preset.
## The dispatcher ## The dispatcher
`Theme.kt` is a **pure lookup** — no special-casing. Given a `ThemePreset` and `ThemeRegistry.kt` maps every `ThemePreset` to one `ThemePalette`. Resolution,
a dark flag, `themeFor()` returns the preset's `ThemePalette`, then the scheme / UI selection, and palette tests use this registry. An exact enum/registry coverage
status are read off it (with the mode fallback above): test rejects missing, duplicate, or reordered entries. Android string resources
stay in the UI’s exhaustive label mapping, outside the palette model.
```kotlin `Theme.kt` reads `preset.palette()` and applies the mode fallback above.
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+ can optionally override the preset scheme when 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 `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 ## Adding a new theme
1. **Create** `presets/<Name>Scheme.kt` as a template fill (see above) — 1. **Create** `presets/<Name>Scheme.kt` as a template fill (see above) —
`buildTheme` for a full theme, `buildThemeDarkOnly` / `buildThemeLightOnly` `buildTheme` for a full theme, `buildThemeDarkOnly` / `buildThemeLightOnly`
for single-mode themes. Every status color must be bespoke — no aliasing. 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`. 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). `ui/settings/components/AppearanceSection.kt` (and any string resource).
5. **Verify**: 5. **Verify**:
```bash ```bash
./gradlew ktlintCheck testDebugUnitTest ./gradlew ktlintCheck testDebugUnitTest
``` ```
`ThemePaletteTest` asserts >= 3:1 contrast on every shipped mode's error `ThemePaletteTest` checks text contrast, complete Material slot mapping,
slot pairs and the ThemeMode invariants — it must stay green. mode-independent fixed roles, registry coverage, and mode/fallback invariants.
## Conventions ## Conventions
@@ -10,12 +10,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.platform.LocalContext 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.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 import kotlinx.serialization.Serializable
@Serializable @Serializable
@@ -28,20 +23,6 @@ val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT } val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
val LocalChatFontScale = compositionLocalOf { 1.0f } 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. * Resolve the Material 3 [ColorScheme] for a preset + dark flag.
* *
@@ -53,7 +34,7 @@ internal fun resolveColorScheme(
preset: ThemePreset, preset: ThemePreset,
darkTheme: Boolean, darkTheme: Boolean,
): ColorScheme { ): ColorScheme {
val theme = themeFor(preset) val theme = preset.palette()
// DefaultTheme is FULL — its scheme is never null for either mode. // DefaultTheme is FULL — its scheme is never null for either mode.
return theme.schemeFor(darkTheme) ?: requireNotNull(DefaultTheme.schemeFor(darkTheme)) return theme.schemeFor(darkTheme) ?: requireNotNull(DefaultTheme.schemeFor(darkTheme))
} }
@@ -66,7 +47,7 @@ internal fun resolveStatusColors(
preset: ThemePreset, preset: ThemePreset,
darkTheme: Boolean, darkTheme: Boolean,
): HermesStatusColors { ): HermesStatusColors {
val theme = themeFor(preset) val theme = preset.palette()
// DefaultTheme is FULL — its status colors are never null for either mode. // DefaultTheme is FULL — its status colors are never null for either mode.
return theme.statusFor(darkTheme) ?: requireNotNull(DefaultTheme.statusFor(darkTheme)) return theme.statusFor(darkTheme) ?: requireNotNull(DefaultTheme.statusFor(darkTheme))
} }
@@ -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
@@ -59,6 +59,20 @@ val AmoledTheme =
onSurface = AmoledTextBright, onSurface = AmoledTextBright,
surfaceVariant = AmoledContainerHigh, surfaceVariant = AmoledContainerHigh,
onSurfaceVariant = AmoledTextDim, 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, surfaceContainerLowest = AmoledFloor,
surfaceContainerLow = AmoledFloor, surfaceContainerLow = AmoledFloor,
surfaceContainer = AmoledContainer, surfaceContainer = AmoledContainer,
@@ -19,7 +19,6 @@ private val MochaRed = Color(0xFFF38BA8)
private val MochaText = Color(0xFFCDD6F4) private val MochaText = Color(0xFFCDD6F4)
private val MochaSubtext1 = Color(0xFFBAC2DE) private val MochaSubtext1 = Color(0xFFBAC2DE)
private val MochaSubtext0 = Color(0xFFA6ADC8) private val MochaSubtext0 = Color(0xFFA6ADC8)
private val MochaOverlay0 = Color(0xFF6C7086)
private val MochaSurface1 = Color(0xFF45475A) private val MochaSurface1 = Color(0xFF45475A)
private val MochaSurface0 = Color(0xFF313244) private val MochaSurface0 = Color(0xFF313244)
private val MochaBase = Color(0xFF1E1E2E) private val MochaBase = Color(0xFF1E1E2E)
@@ -31,24 +30,33 @@ private val MochaCrust = Color(0xFF11111B)
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
private val LatteMauve = Color(0xFF8839EF) 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 LatteBlue = Color(0xFF1E66F5)
private val LattePink = Color(0xFFEA76CB) private val LattePink = Color(0xFFEA76CB)
private val LatteGreen = Color(0xFF40A02B) private val LatteGreen = Color(0xFF40A02B)
private val LatteYellow = Color(0xFFDF8E1D) private val LatteYellow = Color(0xFFDF8E1D)
private val LatteRed = Color(0xFFD20F39) 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 LatteSubtext0 = Color(0xFF6C6F85)
private val LatteOverlay0 = Color(0xFF9CA0B0) private val LatteText = Color(0xFF4C4F69)
private val LatteSurface1 = Color(0xFFBCC0CC) private val LatteSurface1 = Color(0xFFBCC0CC)
private val LatteSurface0 = Color(0xFFCCD0DA) private val LatteSurface0 = Color(0xFFCCD0DA)
private val LatteBase = Color(0xFFEFF1F5) private val LatteBase = Color(0xFFEFF1F5)
private val LatteMantle = Color(0xFFE6E9EF) private val LatteMantle = Color(0xFFE6E9EF)
private val LatteCrust = Color(0xFFDCE0E8) private val LatteCrust = Color(0xFFDCE0E8)
// Bespoke tint used only for the light primary container — not part of // Regression theme-contract: fixed accents use Mocha in both modes. Fixed/Dim
// the official Catppuccin swatch set, kept local to this file. // share official accents rather than inventing tonal steps; on-roles use Crust.
private val LattePrimaryContainer = Color(0xFFE6CAFA) // Latte surface containers stop at Surface0 so normal text stays >= 4.5:1.
private val LatteOnPrimaryContainer = Color(0xFF380075) // Upstream: https://github.com/catppuccin/palette/blob/main/palette.json
/** Catppuccin theme — Mocha (dark) / Latte (light). */ /** Catppuccin theme — Mocha (dark) / Latte (light). */
val CatppuccinTheme = val CatppuccinTheme =
@@ -73,6 +81,20 @@ val CatppuccinTheme =
onSurface = MochaText, onSurface = MochaText,
surfaceVariant = MochaSurface0, surfaceVariant = MochaSurface0,
onSurfaceVariant = MochaSubtext0, 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, surfaceContainerLowest = MochaCrust,
surfaceContainerLow = MochaMantle, surfaceContainerLow = MochaMantle,
surfaceContainer = MochaBase, surfaceContainer = MochaBase,
@@ -81,7 +103,7 @@ val CatppuccinTheme =
inverseSurface = MochaText, inverseSurface = MochaText,
inverseOnSurface = MochaBase, inverseOnSurface = MochaBase,
inversePrimary = LatteMauve, inversePrimary = LatteMauve,
outline = MochaOverlay0, outline = MochaSubtext0,
outlineVariant = MochaSurface1, outlineVariant = MochaSurface1,
scrim = MochaCrust, scrim = MochaCrust,
status = status =
@@ -105,48 +127,62 @@ val CatppuccinTheme =
PaletteColors( PaletteColors(
primary = LatteMauve, primary = LatteMauve,
onPrimary = LatteBase, onPrimary = LatteBase,
primaryContainer = LattePrimaryContainer, primaryContainer = LatteBase,
onPrimaryContainer = LatteOnPrimaryContainer, onPrimaryContainer = LatteMauve,
secondary = LatteBlue, secondary = LatteBlue,
onSecondary = LatteBase, onSecondary = LatteOnBlue,
secondaryContainer = LatteMantle, secondaryContainer = LatteMantle,
onSecondaryContainer = LatteText, onSecondaryContainer = LatteText,
tertiary = LattePink, tertiary = LattePink,
onTertiary = LatteBase, onTertiary = LatteDeepInk,
tertiaryContainer = LatteSurface1, tertiaryContainer = LatteSurface0,
onTertiaryContainer = LatteText, onTertiaryContainer = LatteText,
background = LatteBase, background = LatteBase,
onBackground = LatteText, onBackground = LatteText,
surface = LatteBase, surface = LatteBase,
onSurface = LatteText, onSurface = LatteText,
surfaceVariant = LatteSurface0, 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, surfaceContainerLowest = LatteCrust,
surfaceContainerLow = LatteMantle, surfaceContainerLow = LatteMantle,
surfaceContainer = LatteBase, surfaceContainer = LatteBase,
surfaceContainerHigh = LatteSurface0, surfaceContainerHigh = LatteSurface0,
surfaceContainerHighest = LatteSurface1, surfaceContainerHighest = LatteSurface0,
inverseSurface = LatteText, inverseSurface = LatteText,
inverseOnSurface = LatteBase, inverseOnSurface = LatteBase,
inversePrimary = MochaMauve, inversePrimary = MochaMauve,
outline = LatteOverlay0, outline = LatteSubtext0,
outlineVariant = LatteSurface1, outlineVariant = LatteSurface1,
scrim = LatteCrust, scrim = LatteCrust,
status = status =
HermesStatusColors( HermesStatusColors(
success = LatteGreen, success = LatteGreen,
successContainer = LatteSurface0, successContainer = LatteSurface0,
onSuccess = LatteBase, onSuccess = LatteDeepInk,
warning = LatteYellow, warning = LatteYellow,
warningContainer = LatteSurface0, warningContainer = LatteSurface0,
onWarning = LatteBase, onWarning = LatteDeepInk,
error = LatteRed, error = LatteRed,
errorContainer = LatteSurface0, errorContainer = LatteSurface0,
onError = LatteBase, onError = LatteBase,
onErrorContainer = LatteRed, onErrorContainer = LatteText,
info = LatteBlue, info = LatteBlue,
infoContainer = LatteSurface0, infoContainer = LatteSurface0,
onInfo = LatteBase, onInfo = LatteOnBlue,
), ),
), ),
) )
@@ -73,6 +73,20 @@ val DefaultTheme =
onSurface = SlateDarkFg1, onSurface = SlateDarkFg1,
surfaceVariant = SlateDarkBg1, surfaceVariant = SlateDarkBg1,
onSurfaceVariant = SlateDarkFg3, 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, surfaceContainerLowest = SlateDarkBg0,
surfaceContainerLow = SlateDarkBg1, surfaceContainerLow = SlateDarkBg1,
surfaceContainer = SlateDarkBg2, surfaceContainer = SlateDarkBg2,
@@ -121,6 +135,20 @@ val DefaultTheme =
onSurface = SlateLightFg1, onSurface = SlateLightFg1,
surfaceVariant = SlateLightBg1, surfaceVariant = SlateLightBg1,
onSurfaceVariant = SlateLightFg3, 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, surfaceContainerLowest = SlateLightBg0,
surfaceContainerLow = SlateLightBg1, surfaceContainerLow = SlateLightBg1,
surfaceContainer = SlateLightBg2, surfaceContainer = SlateLightBg2,
@@ -129,7 +157,7 @@ val DefaultTheme =
inverseSurface = SlateLightFg1, inverseSurface = SlateLightFg1,
inverseOnSurface = SlateLightBg0, inverseOnSurface = SlateLightBg0,
inversePrimary = SlateBrightTeal, inversePrimary = SlateBrightTeal,
outline = SlateGray, outline = SlateLightFg3,
outlineVariant = SlateLightBg3, outlineVariant = SlateLightBg3,
scrim = SlateLightBg0, scrim = SlateLightBg0,
status = status =
@@ -9,18 +9,20 @@ import com.m57.hermescontrol.theme.buildTheme
// Gruvbox Dark (medium contrast) — named swatches // 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 GruvboxDarkBg0 = Color(0xFF282828)
private val GruvboxDarkBg1 = Color(0xFF3C3836) private val GruvboxDarkBg1 = Color(0xFF3C3836)
private val GruvboxDarkBg2 = Color(0xFF504945) private val GruvboxDarkBg2 = Color(0xFF504945)
private val GruvboxDarkBg3 = Color(0xFF665C54) private val GruvboxDarkBg3 = Color(0xFF665C54)
private val GruvboxDarkBg4 = Color(0xFF7C6F64)
private val GruvboxDarkFg1 = Color(0xFFEBDBB2) private val GruvboxDarkFg1 = Color(0xFFEBDBB2)
private val GruvboxDarkFg2 = Color(0xFFD5C4A1) private val GruvboxDarkFg2 = Color(0xFFD5C4A1)
private val GruvboxDarkFg3 = Color(0xFFBDAE93) 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. // "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 GruvboxBrightGreen = Color(0xFFB8BB26)
private val GruvboxBrightYellow = Color(0xFFFABD2F) private val GruvboxBrightYellow = Color(0xFFFABD2F)
private val GruvboxBrightBlue = Color(0xFF83A598) private val GruvboxBrightBlue = Color(0xFF83A598)
@@ -35,7 +37,6 @@ private val GruvboxLightBg0 = Color(0xFFFBF1C7)
private val GruvboxLightBg1 = Color(0xFFEBDBB2) private val GruvboxLightBg1 = Color(0xFFEBDBB2)
private val GruvboxLightBg2 = Color(0xFFD5C4A1) private val GruvboxLightBg2 = Color(0xFFD5C4A1)
private val GruvboxLightBg3 = Color(0xFFBDAE93) private val GruvboxLightBg3 = Color(0xFFBDAE93)
private val GruvboxLightBg4 = Color(0xFFA89984)
private val GruvboxLightFg1 = Color(0xFF3C3836) private val GruvboxLightFg1 = Color(0xFF3C3836)
private val GruvboxLightFg2 = Color(0xFF504945) private val GruvboxLightFg2 = Color(0xFF504945)
private val GruvboxLightFg3 = Color(0xFF665C54) private val GruvboxLightFg3 = Color(0xFF665C54)
@@ -43,28 +44,38 @@ private val GruvboxLightFg3 = Color(0xFF665C54)
// "Faded" accents — the muted variants gruvbox uses against a light // "Faded" accents — the muted variants gruvbox uses against a light
// background, so contrast holds up without the neon dark-mode saturation. // background, so contrast holds up without the neon dark-mode saturation.
private val GruvboxFadedRed = Color(0xFF9D0006) 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 GruvboxFadedGreen = Color(0xFF79740E)
private val GruvboxFadedYellow = Color(0xFFB57614) private val GruvboxFadedYellow = Color(0xFFB57614)
private val GruvboxFadedBlue = Color(0xFF076678) private val GruvboxFadedBlue = Color(0xFF076678)
private val GruvboxFadedPurple = Color(0xFF8F3F71) private val GruvboxFadedPurple = Color(0xFF8F3F71)
private val GruvboxFadedOrange = Color(0xFFAF3A03) 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 = val GruvboxTheme =
buildTheme( buildTheme(
dark = dark =
PaletteColors( PaletteColors(
primary = GruvboxBrightOrange, primary = GruvboxBrightOrange,
onPrimary = GruvboxDarkBg0, onPrimary = GruvboxDarkBg0,
primaryContainer = GruvboxDarkBg4, primaryContainer = GruvboxDarkBg2,
onPrimaryContainer = GruvboxDarkFg1, onPrimaryContainer = GruvboxDarkFg1,
secondary = GruvboxBrightBlue, secondary = GruvboxBrightBlue,
onSecondary = GruvboxDarkBg0, onSecondary = GruvboxDarkBg0,
secondaryContainer = GruvboxDarkBg3, secondaryContainer = GruvboxDarkBg2,
onSecondaryContainer = GruvboxDarkFg2, onSecondaryContainer = GruvboxDarkFg2,
tertiary = GruvboxBrightPurple, tertiary = GruvboxBrightPurple,
onTertiary = GruvboxDarkBg0, onTertiary = GruvboxDarkBg0,
tertiaryContainer = GruvboxDarkBg4, tertiaryContainer = GruvboxDarkBg2,
onTertiaryContainer = GruvboxDarkFg1, onTertiaryContainer = GruvboxDarkFg1,
background = GruvboxDarkBg0, background = GruvboxDarkBg0,
onBackground = GruvboxDarkFg1, onBackground = GruvboxDarkFg1,
@@ -72,15 +83,29 @@ val GruvboxTheme =
onSurface = GruvboxDarkFg1, onSurface = GruvboxDarkFg1,
surfaceVariant = GruvboxDarkBg1, surfaceVariant = GruvboxDarkBg1,
onSurfaceVariant = GruvboxDarkFg3, 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, surfaceContainerLowest = GruvboxDarkBg0,
surfaceContainerLow = GruvboxDarkBg1, surfaceContainerLow = GruvboxDarkBg1,
surfaceContainer = GruvboxDarkBg2, surfaceContainer = GruvboxDarkBg2,
surfaceContainerHigh = GruvboxDarkBg3, surfaceContainerHigh = GruvboxDarkBg3,
surfaceContainerHighest = GruvboxDarkBg4, surfaceContainerHighest = GruvboxDarkBg3,
inverseSurface = GruvboxDarkFg1, inverseSurface = GruvboxDarkFg1,
inverseOnSurface = GruvboxDarkBg0, inverseOnSurface = GruvboxDarkBg0,
inversePrimary = GruvboxFadedOrange, inversePrimary = GruvboxFadedOrange,
outline = GruvboxGray, outline = GruvboxDarkFg2,
outlineVariant = GruvboxDarkBg3, outlineVariant = GruvboxDarkBg3,
scrim = GruvboxDarkBg0, scrim = GruvboxDarkBg0,
status = status =
@@ -91,9 +116,9 @@ val GruvboxTheme =
warning = GruvboxBrightYellow, warning = GruvboxBrightYellow,
warningContainer = GruvboxDarkBg3, warningContainer = GruvboxDarkBg3,
onWarning = GruvboxDarkBg0, onWarning = GruvboxDarkBg0,
error = GruvboxBrightRed, error = GruvboxErrorText,
errorContainer = GruvboxDarkBg3, errorContainer = GruvboxDarkBg3,
onError = GruvboxDarkBg0, onError = GruvboxDarkInk,
onErrorContainer = GruvboxDarkFg1, onErrorContainer = GruvboxDarkFg1,
info = GruvboxBrightBlue, info = GruvboxBrightBlue,
infoContainer = GruvboxDarkBg3, infoContainer = GruvboxDarkBg3,
@@ -104,15 +129,15 @@ val GruvboxTheme =
PaletteColors( PaletteColors(
primary = GruvboxFadedOrange, primary = GruvboxFadedOrange,
onPrimary = GruvboxLightBg0, onPrimary = GruvboxLightBg0,
primaryContainer = GruvboxLightBg4, primaryContainer = GruvboxLightBg2,
onPrimaryContainer = GruvboxLightFg1, onPrimaryContainer = GruvboxLightFg1,
secondary = GruvboxFadedBlue, secondary = GruvboxFadedBlue,
onSecondary = GruvboxLightBg0, onSecondary = GruvboxLightBg0,
secondaryContainer = GruvboxLightBg3, secondaryContainer = GruvboxLightBg2,
onSecondaryContainer = GruvboxLightFg2, onSecondaryContainer = GruvboxLightFg2,
tertiary = GruvboxFadedPurple, tertiary = GruvboxFadedPurple,
onTertiary = GruvboxLightBg0, onTertiary = GruvboxLightBg0,
tertiaryContainer = GruvboxLightBg4, tertiaryContainer = GruvboxLightBg2,
onTertiaryContainer = GruvboxLightFg1, onTertiaryContainer = GruvboxLightFg1,
background = GruvboxLightBg0, background = GruvboxLightBg0,
onBackground = GruvboxLightFg1, onBackground = GruvboxLightFg1,
@@ -120,29 +145,43 @@ val GruvboxTheme =
onSurface = GruvboxLightFg1, onSurface = GruvboxLightFg1,
surfaceVariant = GruvboxLightBg1, surfaceVariant = GruvboxLightBg1,
onSurfaceVariant = GruvboxLightFg3, 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, surfaceContainerLowest = GruvboxLightBg0,
surfaceContainerLow = GruvboxLightBg1, surfaceContainerLow = GruvboxLightBg1,
surfaceContainer = GruvboxLightBg2, surfaceContainer = GruvboxLightBg2,
surfaceContainerHigh = GruvboxLightBg3, surfaceContainerHigh = GruvboxLightBg3,
surfaceContainerHighest = GruvboxLightBg4, surfaceContainerHighest = GruvboxLightBg3,
inverseSurface = GruvboxLightFg1, inverseSurface = GruvboxLightFg1,
inverseOnSurface = GruvboxLightBg0, inverseOnSurface = GruvboxLightBg0,
inversePrimary = GruvboxBrightOrange, inversePrimary = GruvboxBrightOrange,
outline = GruvboxGray, outline = GruvboxLightFg2,
outlineVariant = GruvboxLightBg3, outlineVariant = GruvboxLightBg3,
scrim = GruvboxLightBg0, scrim = GruvboxLightBg0,
status = status =
HermesStatusColors( HermesStatusColors(
success = GruvboxFadedGreen, success = GruvboxFadedGreen,
successContainer = GruvboxLightBg3, successContainer = GruvboxLightBg3,
onSuccess = GruvboxLightBg0, onSuccess = GruvboxOnFadedGreen,
warning = GruvboxFadedYellow, warning = GruvboxFadedYellow,
warningContainer = GruvboxLightBg3, warningContainer = GruvboxLightBg3,
onWarning = GruvboxLightBg0, onWarning = GruvboxOnFadedYellow,
error = GruvboxFadedRed, error = GruvboxFadedRed,
errorContainer = GruvboxLightBg3, errorContainer = GruvboxLightBg3,
onError = GruvboxLightBg0, onError = GruvboxLightBg0,
onErrorContainer = GruvboxFadedRed, onErrorContainer = GruvboxLightFg1,
info = GruvboxFadedBlue, info = GruvboxFadedBlue,
infoContainer = GruvboxLightBg3, infoContainer = GruvboxLightBg3,
onInfo = GruvboxLightBg0, onInfo = GruvboxLightBg0,
@@ -32,7 +32,6 @@ private val MonoLightBg = Color(0xFFFAFAFA) // background, surface, containerLow
private val MonoLightContainer = Color(0xFFF0F0F0) // surfaceContainer private val MonoLightContainer = Color(0xFFF0F0F0) // surfaceContainer
private val MonoLightContainerHigh = Color(0xFFE0E0E0) // surfaceContainerHigh, surfaceVariant, status containers private val MonoLightContainerHigh = Color(0xFFE0E0E0) // surfaceContainerHigh, surfaceVariant, status containers
private val MonoLightContainerHighest = Color(0xFFD1D1D1) // surfaceContainerHighest, primaryContainer private val MonoLightContainerHighest = Color(0xFFD1D1D1) // surfaceContainerHighest, primaryContainer
private val MonoLightOutline = Color(0xFF808080)
private val MonoLightOutlineVariant = Color(0xFFCCCCCC) private val MonoLightOutlineVariant = Color(0xFFCCCCCC)
private val MonoLightTextDim = Color(0xFF4D4D4D) // onSurfaceVariant private val MonoLightTextDim = Color(0xFF4D4D4D) // onSurfaceVariant
private val MonoLightTextBright = Color(0xFF121212) // onBackground/onSurface/inverseSurface/primary/error private val MonoLightTextBright = Color(0xFF121212) // onBackground/onSurface/inverseSurface/primary/error
@@ -71,6 +70,20 @@ val MonochromeTheme =
onSurface = MonoDarkTextBright, onSurface = MonoDarkTextBright,
surfaceVariant = MonoDarkContainerHigh, surfaceVariant = MonoDarkContainerHigh,
onSurfaceVariant = MonoDarkTextDim, 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, surfaceContainerLowest = MonoDarkFloor,
surfaceContainerLow = MonoDarkBg, surfaceContainerLow = MonoDarkBg,
surfaceContainer = MonoDarkContainer, surfaceContainer = MonoDarkContainer,
@@ -112,13 +125,27 @@ val MonochromeTheme =
tertiary = MonoLightTertiary, tertiary = MonoLightTertiary,
onTertiary = MonoLightBg, onTertiary = MonoLightBg,
tertiaryContainer = MonoLightContainerHigh, tertiaryContainer = MonoLightContainerHigh,
onTertiaryContainer = MonoLightTertiary, onTertiaryContainer = MonoLightSecondary,
background = MonoLightBg, background = MonoLightBg,
onBackground = MonoLightTextBright, onBackground = MonoLightTextBright,
surface = MonoLightBg, surface = MonoLightBg,
onSurface = MonoLightTextBright, onSurface = MonoLightTextBright,
surfaceVariant = MonoLightContainerHigh, surfaceVariant = MonoLightContainerHigh,
onSurfaceVariant = MonoLightTextDim, 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, surfaceContainerLowest = MonoLightCeiling,
surfaceContainerLow = MonoLightBg, surfaceContainerLow = MonoLightBg,
surfaceContainer = MonoLightContainer, surfaceContainer = MonoLightContainer,
@@ -127,7 +154,7 @@ val MonochromeTheme =
inverseSurface = MonoLightTextBright, inverseSurface = MonoLightTextBright,
inverseOnSurface = MonoLightBg, inverseOnSurface = MonoLightBg,
inversePrimary = MonoDarkTextBright, inversePrimary = MonoDarkTextBright,
outline = MonoLightOutline, outline = MonoLightTertiary,
outlineVariant = MonoLightOutlineVariant, outlineVariant = MonoLightOutlineVariant,
scrim = Color.Black, scrim = Color.Black,
status = status =
@@ -144,7 +171,7 @@ val MonochromeTheme =
onErrorContainer = MonoLightTextBright, onErrorContainer = MonoLightTextBright,
info = MonoLightInfo, info = MonoLightInfo,
infoContainer = MonoLightContainerHigh, infoContainer = MonoLightContainerHigh,
onInfo = MonoLightBg, onInfo = MonoLightTextBright,
), ),
), ),
) )
@@ -11,9 +11,12 @@ import com.m57.hermescontrol.theme.buildTheme
// supply the surfaces; Frost (7–10) and Aurora (11–15) supply the // supply the surfaces; Frost (7–10) and Aurora (11–15) supply the
// accents. Unlike Gruvbox, Nord defines one accent set — not a // accents. Unlike Gruvbox, Nord defines one accent set — not a
// separate bright/faded pair per mode — so the same accent swatches // 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 Nord0 = Color(0xFF2E3440) // Polar Night — darkest
private val Nord1 = Color(0xFF3B4252) private val Nord1 = Color(0xFF3B4252)
private val Nord2 = Color(0xFF434C5E) private val Nord2 = Color(0xFF434C5E)
@@ -23,11 +26,23 @@ private val Nord5 = Color(0xFFE5E9F0)
private val Nord6 = Color(0xFFECEFF4) // Snow Storm — lightest private val Nord6 = Color(0xFFECEFF4) // Snow Storm — lightest
private val Nord8 = Color(0xFF88C0D0) // Frost — primary accent private val Nord8 = Color(0xFF88C0D0) // Frost — primary accent
private val Nord9 = Color(0xFF81A1C1) // Frost — secondary / info 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 Nord13 = Color(0xFFEBCB8B) // Aurora yellow — warning
private val Nord14 = Color(0xFFA3BE8C) // Aurora green — success private val Nord14 = Color(0xFFA3BE8C) // Aurora green — success
private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary 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. * Nord theme.
* *
@@ -36,12 +51,11 @@ private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary
* tiers on each side share a value (surfaceContainerHigh == * tiers on each side share a value (surfaceContainerHigh ==
* surfaceContainerHighest) rather than inventing an off-palette hex. * surfaceContainerHighest) rather than inventing an off-palette hex.
* *
* Frost/Aurora accents are pastel by design, so onPrimary/onSecondary/ * Pastel Frost/Aurora fills use dark ink in both modes — reusing
* onTertiary/status "on" colors use Polar Night (dark text) in *both* * Snow Storm for light-mode "on" text (the convention in
* modes — reusing Snow Storm for light-mode "on" text (the convention in
* the Catppuccin/Gruvbox presets) would fail contrast against colors like * the Catppuccin/Gruvbox presets) would fail contrast against colors like
* Aurora yellow, which stays light even without a separate light-mode * 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 = val NordTheme =
buildTheme( buildTheme(
@@ -56,7 +70,7 @@ val NordTheme =
secondaryContainer = Nord2, secondaryContainer = Nord2,
onSecondaryContainer = Nord4, onSecondaryContainer = Nord4,
tertiary = Nord15, tertiary = Nord15,
onTertiary = Nord0, onTertiary = NordPolarInk,
tertiaryContainer = Nord3, tertiaryContainer = Nord3,
onTertiaryContainer = Nord6, onTertiaryContainer = Nord6,
background = Nord0, background = Nord0,
@@ -65,6 +79,20 @@ val NordTheme =
onSurface = Nord6, onSurface = Nord6,
surfaceVariant = Nord1, surfaceVariant = Nord1,
onSurfaceVariant = Nord4, 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, surfaceContainerLowest = Nord0,
surfaceContainerLow = Nord1, surfaceContainerLow = Nord1,
surfaceContainer = Nord2, surfaceContainer = Nord2,
@@ -72,8 +100,8 @@ val NordTheme =
surfaceContainerHighest = Nord3, surfaceContainerHighest = Nord3,
inverseSurface = Nord6, inverseSurface = Nord6,
inverseOnSurface = Nord0, inverseOnSurface = Nord0,
inversePrimary = Nord8, inversePrimary = NordFrostInk,
outline = Nord3, outline = Nord4,
outlineVariant = Nord2, outlineVariant = Nord2,
scrim = Nord0, scrim = Nord0,
status = status =
@@ -84,9 +112,9 @@ val NordTheme =
warning = Nord13, warning = Nord13,
warningContainer = Nord2, warningContainer = Nord2,
onWarning = Nord0, onWarning = Nord0,
error = Nord11, error = NordErrorBright,
errorContainer = Nord2, errorContainer = Nord2,
onError = Nord0, onError = NordPolarInk,
onErrorContainer = Nord6, onErrorContainer = Nord6,
info = Nord9, info = Nord9,
infoContainer = Nord2, infoContainer = Nord2,
@@ -95,8 +123,8 @@ val NordTheme =
), ),
light = light =
PaletteColors( PaletteColors(
primary = Nord8, primary = NordFrostInk,
onPrimary = Nord0, onPrimary = Nord6,
primaryContainer = Nord4, primaryContainer = Nord4,
onPrimaryContainer = Nord0, onPrimaryContainer = Nord0,
secondary = Nord9, secondary = Nord9,
@@ -104,7 +132,7 @@ val NordTheme =
secondaryContainer = Nord5, secondaryContainer = Nord5,
onSecondaryContainer = Nord1, onSecondaryContainer = Nord1,
tertiary = Nord15, tertiary = Nord15,
onTertiary = Nord0, onTertiary = NordPolarInk,
tertiaryContainer = Nord4, tertiaryContainer = Nord4,
onTertiaryContainer = Nord0, onTertiaryContainer = Nord0,
background = Nord6, background = Nord6,
@@ -113,6 +141,20 @@ val NordTheme =
onSurface = Nord0, onSurface = Nord0,
surfaceVariant = Nord5, surfaceVariant = Nord5,
onSurfaceVariant = Nord2, 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, surfaceContainerLowest = Nord6,
surfaceContainerLow = Nord5, surfaceContainerLow = Nord5,
surfaceContainer = Nord5, surfaceContainer = Nord5,
@@ -132,10 +174,10 @@ val NordTheme =
warning = Nord13, warning = Nord13,
warningContainer = Nord5, warningContainer = Nord5,
onWarning = Nord0, onWarning = Nord0,
error = Nord11, error = NordErrorInk,
errorContainer = Nord5, errorContainer = Nord5,
onError = Nord0, onError = Nord6,
onErrorContainer = Nord11, onErrorContainer = Nord0,
info = Nord9, info = Nord9,
infoContainer = Nord5, infoContainer = Nord5,
onInfo = Nord0, onInfo = Nord0,
@@ -40,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesStatusColors
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.theme.LocalSpacing
@@ -81,28 +82,8 @@ fun StatusBadge(
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
val (bgColor, fgColor) = val (bgColor, fgColor) =
when (status) { statusBadgeColors(status, statusColors)
StatusBadgeType.SUCCESS -> { ?: (MaterialTheme.colorScheme.surfaceContainerHigh to MaterialTheme.colorScheme.onSurfaceVariant)
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
}
}
Surface( Surface(
modifier = modifier, modifier = modifier,
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
@@ -135,6 +116,20 @@ fun StatusBadge(
enum class StatusBadgeType { SUCCESS, WARNING, ERROR, INFO, NEUTRAL } 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<Color, Color>? =
// 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 ──────────────────── // ── ToggleRow — switch row with label + description ────────────────────
@Composable @Composable
@@ -25,6 +25,8 @@ import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionLiveStatus import com.m57.hermescontrol.data.model.SessionLiveStatus
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.common.statusBadgeColors
@Composable @Composable
fun SessionLiveStatusIndicator( fun SessionLiveStatusIndicator(
@@ -35,27 +37,26 @@ fun SessionLiveStatusIndicator(
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
val (bgColor, fgColor, textRes, descRes) = val (badgeType, textRes, descRes) =
when (liveStatus) { when (liveStatus) {
SessionLiveStatus.WORKING -> { SessionLiveStatus.WORKING -> {
Tuple4( Triple(
statusColors.successContainer, StatusBadgeType.SUCCESS,
statusColors.success,
R.string.sessions_live_status_running, R.string.sessions_live_status_running,
R.string.sessions_live_status_running_desc, R.string.sessions_live_status_running_desc,
) )
} }
SessionLiveStatus.WAITING -> { SessionLiveStatus.WAITING -> {
Tuple4( Triple(
statusColors.warningContainer, StatusBadgeType.WARNING,
statusColors.warning,
R.string.sessions_live_status_waiting, R.string.sessions_live_status_waiting,
R.string.sessions_live_status_waiting_desc, R.string.sessions_live_status_waiting_desc,
) )
} }
} }
val (bgColor, fgColor) = requireNotNull(statusBadgeColors(badgeType, statusColors))
val text = stringResource(textRes) val text = stringResource(textRes)
val description = stringResource(descRes) val description = stringResource(descRes)
@@ -103,10 +104,3 @@ fun SessionLiveStatusIndicator(
} }
} }
} }
private data class Tuple4<A, B, C, D>(
val first: A,
val second: B,
val third: C,
val fourth: D,
)
@@ -14,7 +14,6 @@ import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -270,11 +269,6 @@ private fun ReleaseCandidateRow(
checked = checked, checked = checked,
onCheckedChange = onCheckedChange, onCheckedChange = onCheckedChange,
enabled = enabled, enabled = enabled,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
) )
} }
} }
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.settings.components package com.m57.hermescontrol.ui.settings.components
import android.os.Build
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -21,7 +22,6 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider 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.data.model.BusySendMode
import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.theme.ThemeRegistry
import com.m57.hermescontrol.ui.chat.label import com.m57.hermescontrol.ui.chat.label
import com.m57.hermescontrol.ui.settings.SectionCard import com.m57.hermescontrol.ui.settings.SectionCard
import kotlin.math.abs import kotlin.math.abs
@@ -55,6 +56,7 @@ internal fun AppearanceSection(
themePreset: ThemePreset, themePreset: ThemePreset,
onThemePresetChange: (ThemePreset) -> Unit, onThemePresetChange: (ThemePreset) -> Unit,
) { ) {
val dynamicAvailable = useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
SectionCard { SectionCard {
Text( Text(
text = stringResource(R.string.settings_item_theme), text = stringResource(R.string.settings_item_theme),
@@ -106,11 +108,6 @@ internal fun AppearanceSection(
Switch( Switch(
checked = useDynamicColors, checked = useDynamicColors,
onCheckedChange = onUseDynamicColorsChange, 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)) 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) } var presetsExpanded by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
OutlinedButton( OutlinedButton(
onClick = { presetsExpanded = true }, onClick = { presetsExpanded = true },
enabled = !useDynamicColors,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(themePreset.label())
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)
},
)
} }
DropdownMenu( DropdownMenu(
expanded = presetsExpanded, expanded = presetsExpanded,
onDismissRequest = { presetsExpanded = false }, onDismissRequest = { presetsExpanded = false },
modifier = Modifier.fillMaxWidth(0.85f), modifier = Modifier.fillMaxWidth(0.85f),
) { ) {
ThemePreset.entries.forEach { preset -> ThemeRegistry.forEach { (preset, _) ->
DropdownMenuItem( DropdownMenuItem(
text = { text = { Text(preset.label()) },
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,
)
}
},
)
},
onClick = { onClick = {
onThemePresetChange(preset) onThemePresetChange(preset)
presetsExpanded = false 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 @Composable
internal fun ChatSection( internal fun ChatSection(
typingEffectEnabled: Boolean, typingEffectEnabled: Boolean,
@@ -485,11 +453,6 @@ internal fun ChatSection(
checked = showModelProvider, checked = showModelProvider,
onCheckedChange = onShowModelProviderChange, onCheckedChange = onShowModelProviderChange,
modifier = Modifier.testTag("settings_show_model_provider"), 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( Switch(
checked = typingEffectEnabled, checked = typingEffectEnabled,
onCheckedChange = onTypingEffectEnabledChange, onCheckedChange = onTypingEffectEnabledChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
) )
} }
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -50,11 +49,6 @@ internal fun BehaviorSection(
Switch( Switch(
checked = autoReconnect, checked = autoReconnect,
onCheckedChange = onAutoReconnectChange, onCheckedChange = onAutoReconnectChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
) )
} }
@@ -83,11 +77,6 @@ internal fun BehaviorSection(
Switch( Switch(
checked = keepConnectedInBackground, checked = keepConnectedInBackground,
onCheckedChange = onKeepConnectedInBackgroundChange, onCheckedChange = onKeepConnectedInBackgroundChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
) )
} }
@@ -116,11 +105,6 @@ internal fun BehaviorSection(
Switch( Switch(
checked = restoreLastSession, checked = restoreLastSession,
onCheckedChange = onRestoreLastSessionChange, onCheckedChange = onRestoreLastSessionChange,
colors =
SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.primary,
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
),
) )
} }
} }
+1
View File
@@ -1845,4 +1845,5 @@
<string name="mcp_tab_catalog">Catalog</string> <string name="mcp_tab_catalog">Catalog</string>
<string name="mcp_servers_catalog_empty_title">No catalog entries</string> <string name="mcp_servers_catalog_empty_title">No catalog entries</string>
<string name="mcp_servers_catalog_installed">Installed</string> <string name="mcp_servers_catalog_installed">Installed</string>
<string name="settings_desc_theme_preset_dynamic">Used for status colors while Material You supplies the app colors.</string>
</resources> </resources>
@@ -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<Color, Color>,
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)
}
}
@@ -1,67 +1,164 @@
package com.m57.hermescontrol.theme package com.m57.hermescontrol.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import com.m57.hermescontrol.theme.presets.AmoledTheme 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.DefaultTheme
import com.m57.hermescontrol.theme.presets.GruvboxTheme import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.theme.presets.MonochromeTheme import com.m57.hermescontrol.ui.common.statusBadgeColors
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertThrows import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import java.lang.reflect.Modifier
/** /**
* Guards the palette template's color invariants: * 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. * 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 { class ThemePaletteTest {
private val themes = @Test
listOf( fun registryCoversEveryPresetExactlyOnce() {
ThemePreset.DEFAULT to DefaultTheme, assertEquals(ThemePreset.entries, ThemeRegistry.map { it.preset })
ThemePreset.MONOCHROME to MonochromeTheme, }
ThemePreset.GRUVBOX to GruvboxTheme,
ThemePreset.CATPPUCCIN to CatppuccinTheme,
ThemePreset.AMOLED to AmoledTheme,
)
@Test @Test
fun errorSlotPairsMeetContrastInEveryShippedMode() { fun textSlotPairsMeetContrastInEveryResolvedMode() {
themes.forEach { (preset, theme) -> ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark -> listOf(true, false).forEach { dark ->
val scheme = theme.schemeFor(dark) ?: return@forEach val c = resolveColorScheme(preset, dark)
assertTrue( val pairs =
"$preset dark=$dark onError/error contrast must be >= 3:1", listOf(
contrast(scheme.onError, scheme.error) >= 3f, "onPrimary/primary" to (c.onPrimary to c.primary),
) "onPrimaryContainer/primaryContainer" to (c.onPrimaryContainer to c.primaryContainer),
assertTrue( "onSecondary/secondary" to (c.onSecondary to c.secondary),
"$preset dark=$dark onErrorContainer/errorContainer contrast must be >= 3:1", "onSecondaryContainer/secondaryContainer" to (c.onSecondaryContainer to c.secondaryContainer),
contrast(scheme.onErrorContainer, scheme.errorContainer) >= 3f, "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<String, Long> =
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 * Full-bleed chat renderer gate (issue #866): agent prose renders directly
* on the screen background (HermesScaffold containerColor = background), * on the screen background (HermesScaffold containerColor = background),
* so the full-bleed text pairs must hold against [ColorScheme.background]: * so the full-bleed text pairs must hold against [ColorScheme.background]:
* - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text * - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text
* - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI * - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI
* (The header deliberately avoids `primary`: Nord's light mode reuses its * Header text uses onSurfaceVariant independently of the theme's accent.
* pastel Frost accent as primary, which cannot reach 3:1 on a light * The registry covers every shipped preset and its fallback mode.
* background.)
* Iterates ThemePreset.entries directly so EVERY shipped preset (incl.
* NORD, which the [themes] list above predates) is covered.
*/ */
@Test @Test
fun fullBleedTextPairsMeetContrastInEveryShippedMode() { fun fullBleedTextPairsMeetContrastInEveryShippedMode() {
ThemePreset.entries.forEach { preset -> ThemeRegistry.forEach { (preset, _) ->
listOf(true, false).forEach { dark -> listOf(true, false).forEach { dark ->
val scheme = resolveColorScheme(preset, darkTheme = dark) ?: return@forEach val scheme = resolveColorScheme(preset, darkTheme = dark)
assertTrue( assertTrue(
"$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)", "$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)",
contrast(scheme.onSurface, scheme.background) >= 4.5f, contrast(scheme.onSurface, scheme.background) >= 4.5f,
@@ -84,18 +181,18 @@ class ThemePaletteTest {
@Test @Test
fun everyPresetResolvesItsDeclaredModes() { fun everyPresetResolvesItsDeclaredModes() {
themes.forEach { (preset, theme) -> ThemeRegistry.forEach { (preset, theme) ->
listOf(true, false).forEach { dark -> listOf(true, false).forEach { dark ->
if (theme.schemeFor(dark) != null) { assertSame(
assertTrue( "$preset dark=$dark must resolve its palette or Default fallback",
"$preset dark=$dark scheme must resolve", theme.schemeFor(dark) ?: DefaultTheme.schemeFor(dark),
resolveColorScheme(preset, darkTheme = dark) != null, resolveColorScheme(preset, dark),
) )
assertTrue( assertSame(
"$preset dark=$dark status must resolve", "$preset dark=$dark must resolve its status or Default fallback",
resolveStatusColors(preset, darkTheme = dark) != null, theme.statusFor(dark) ?: DefaultTheme.statusFor(dark),
) resolveStatusColors(preset, dark),
} )
} }
} }
} }
@@ -169,50 +266,64 @@ class ThemePaletteTest {
private fun dummyColors(): PaletteColors = private fun dummyColors(): PaletteColors =
PaletteColors( PaletteColors(
primary = Color.White, primary = Color(0xFF000000L + 1),
onPrimary = Color.Black, onPrimary = Color(0xFF000000L + 2),
primaryContainer = Color.White, primaryContainer = Color(0xFF000000L + 3),
onPrimaryContainer = Color.Black, onPrimaryContainer = Color(0xFF000000L + 4),
secondary = Color.White, secondary = Color(0xFF000000L + 5),
onSecondary = Color.Black, onSecondary = Color(0xFF000000L + 6),
secondaryContainer = Color.White, secondaryContainer = Color(0xFF000000L + 7),
onSecondaryContainer = Color.Black, onSecondaryContainer = Color(0xFF000000L + 8),
tertiary = Color.White, tertiary = Color(0xFF000000L + 9),
onTertiary = Color.Black, onTertiary = Color(0xFF000000L + 10),
tertiaryContainer = Color.White, tertiaryContainer = Color(0xFF000000L + 11),
onTertiaryContainer = Color.Black, onTertiaryContainer = Color(0xFF000000L + 12),
background = Color.White, background = Color(0xFF000000L + 13),
onBackground = Color.Black, onBackground = Color(0xFF000000L + 14),
surface = Color.White, surface = Color(0xFF000000L + 15),
onSurface = Color.Black, onSurface = Color(0xFF000000L + 16),
surfaceVariant = Color.White, surfaceVariant = Color(0xFF000000L + 17),
onSurfaceVariant = Color.Black, onSurfaceVariant = Color(0xFF000000L + 18),
surfaceContainerLowest = Color.White, surfaceDim = Color(0xFF000000L + 19),
surfaceContainerLow = Color.White, surfaceBright = Color(0xFF000000L + 20),
surfaceContainer = Color.White, primaryFixed = Color(0xFF000000L + 21),
surfaceContainerHigh = Color.White, primaryFixedDim = Color(0xFF000000L + 22),
surfaceContainerHighest = Color.White, onPrimaryFixed = Color(0xFF000000L + 23),
inverseSurface = Color.Black, onPrimaryFixedVariant = Color(0xFF000000L + 24),
inverseOnSurface = Color.White, secondaryFixed = Color(0xFF000000L + 25),
inversePrimary = Color.Black, secondaryFixedDim = Color(0xFF000000L + 26),
outline = Color.Gray, onSecondaryFixed = Color(0xFF000000L + 27),
outlineVariant = Color.Gray, onSecondaryFixedVariant = Color(0xFF000000L + 28),
scrim = Color.Black, 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 = status =
HermesStatusColors( HermesStatusColors(
success = Color.Green, success = Color(0xFF000000L + 100),
successContainer = Color.White, successContainer = Color(0xFF000000L + 101),
onSuccess = Color.Black, onSuccess = Color(0xFF000000L + 102),
warning = Color.Yellow, warning = Color(0xFF000000L + 103),
warningContainer = Color.White, warningContainer = Color(0xFF000000L + 104),
onWarning = Color.Black, onWarning = Color(0xFF000000L + 105),
error = Color.Red, error = Color(0xFF000000L + 106),
errorContainer = Color.White, errorContainer = Color(0xFF000000L + 107),
onError = Color.Black, onError = Color(0xFF000000L + 108),
onErrorContainer = Color.Black, onErrorContainer = Color(0xFF000000L + 109),
info = Color.Blue, info = Color(0xFF000000L + 110),
infoContainer = Color.White, infoContainer = Color(0xFF000000L + 111),
onInfo = Color.Black, onInfo = Color(0xFF000000L + 112),
), ),
) )
} }