fix(theme): complete color and component contrast contracts (#1417)
This commit is contained in:
22 files changed
+1013
-318
No files matched your search
@@ -23,7 +23,7 @@ Android layout dimensions use `dp`; text sizes, line heights, and tracking use `
|
||||
|
||||
| Concern | Authoritative source |
|
||||
| --- | --- |
|
||||
| Presets and mode selection | [Theme.kt](app/src/main/java/com/m57/hermescontrol/theme/Theme.kt) |
|
||||
| Presets and mode selection | [ThemeRegistry.kt](app/src/main/java/com/m57/hermescontrol/theme/ThemeRegistry.kt), [Theme.kt](app/src/main/java/com/m57/hermescontrol/theme/Theme.kt) |
|
||||
| Material slot mapping | [PaletteTemplate.kt](app/src/main/java/com/m57/hermescontrol/theme/PaletteTemplate.kt) |
|
||||
| Palette values | [presets/](app/src/main/java/com/m57/hermescontrol/theme/presets/) |
|
||||
| Semantic status model | [HermesStatusColors.kt](app/src/main/java/com/m57/hermescontrol/theme/HermesStatusColors.kt) |
|
||||
@@ -144,8 +144,11 @@ passed an accessibility audit.
|
||||
- **Narrow layouts**: Check long localized labels, keyboard-open states, and scrolling at small widths; density must not hide essential actions.
|
||||
|
||||
[ThemePaletteTest.kt](app/src/test/java/com/m57/hermescontrol/theme/ThemePaletteTest.kt)
|
||||
currently guards error slot pairs, full-bleed prose/header contrast, and theme-mode
|
||||
invariants. Those tests are useful but do not establish contrast or accessibility
|
||||
guards normal-text slot pairs, full-bleed prose/header contrast, complete Material
|
||||
role mapping, registry coverage, fixed-role mode invariance, and theme-mode invariants.
|
||||
[ThemeComponentContrastTest.kt](app/src/test/java/com/m57/hermescontrol/theme/ThemeComponentContrastTest.kt)
|
||||
and the instrumented component gallery also guard enabled Material control states.
|
||||
Those tests are useful but do not establish contrast or accessibility
|
||||
compliance for every rendered component, alpha blend, or dynamic palette.
|
||||
Record device/emulator steps and observed results for changed UI; explicitly name
|
||||
any gate that could not be verified.
|
||||
@@ -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 }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
+238
@@ -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
|
||||
* wallpaper, but success / warning / error / info colours must stay
|
||||
* semantically correct (green = good, red = bad) regardless of what the
|
||||
* wallpaper-derived palette would choose. This struct is provided via
|
||||
* [LocalHermesStatusColors] and should be used for all status indicators,
|
||||
* wallpaper-derived palette would choose.
|
||||
*
|
||||
* `onSuccess`/`onWarning`/`onError`/`onInfo` are foregrounds on the matching
|
||||
* status fills, not on their container colors. Reusable status badges use
|
||||
* these fill/on pairs; error-container content uses `onErrorContainer`.
|
||||
*
|
||||
* Use [LocalHermesStatusColors] for all status indicators,
|
||||
* badges, and semantic feedback colours.
|
||||
*/
|
||||
data class HermesStatusColors(
|
||||
|
||||
@@ -28,6 +28,9 @@ enum class ThemeMode { FULL, DARK_ONLY, LIGHT_ONLY }
|
||||
*
|
||||
* The Material error slots are derived from [status] — the theme author
|
||||
* defines semantic colors once and the scheme inherits them.
|
||||
* [primary] and the status error are also used directly for text on surfaces.
|
||||
* [outline] must contrast with [surface] and [surfaceContainerHighest], which
|
||||
* Material uses as the unchecked switch track.
|
||||
*/
|
||||
data class PaletteColors(
|
||||
val primary: Color,
|
||||
@@ -48,6 +51,20 @@ data class PaletteColors(
|
||||
val onSurface: Color,
|
||||
val surfaceVariant: Color,
|
||||
val onSurfaceVariant: Color,
|
||||
val surfaceDim: Color,
|
||||
val surfaceBright: Color,
|
||||
val primaryFixed: Color,
|
||||
val primaryFixedDim: Color,
|
||||
val onPrimaryFixed: Color,
|
||||
val onPrimaryFixedVariant: Color,
|
||||
val secondaryFixed: Color,
|
||||
val secondaryFixedDim: Color,
|
||||
val onSecondaryFixed: Color,
|
||||
val onSecondaryFixedVariant: Color,
|
||||
val tertiaryFixed: Color,
|
||||
val tertiaryFixedDim: Color,
|
||||
val onTertiaryFixed: Color,
|
||||
val onTertiaryFixedVariant: Color,
|
||||
val surfaceContainerLowest: Color,
|
||||
val surfaceContainerLow: Color,
|
||||
val surfaceContainer: Color,
|
||||
@@ -123,6 +140,20 @@ private fun darkSchemeOf(c: PaletteColors): ColorScheme =
|
||||
surfaceVariant = c.surfaceVariant,
|
||||
onSurfaceVariant = c.onSurfaceVariant,
|
||||
surfaceTint = c.primary,
|
||||
surfaceDim = c.surfaceDim,
|
||||
surfaceBright = c.surfaceBright,
|
||||
primaryFixed = c.primaryFixed,
|
||||
primaryFixedDim = c.primaryFixedDim,
|
||||
onPrimaryFixed = c.onPrimaryFixed,
|
||||
onPrimaryFixedVariant = c.onPrimaryFixedVariant,
|
||||
secondaryFixed = c.secondaryFixed,
|
||||
secondaryFixedDim = c.secondaryFixedDim,
|
||||
onSecondaryFixed = c.onSecondaryFixed,
|
||||
onSecondaryFixedVariant = c.onSecondaryFixedVariant,
|
||||
tertiaryFixed = c.tertiaryFixed,
|
||||
tertiaryFixedDim = c.tertiaryFixedDim,
|
||||
onTertiaryFixed = c.onTertiaryFixed,
|
||||
onTertiaryFixedVariant = c.onTertiaryFixedVariant,
|
||||
surfaceContainerLowest = c.surfaceContainerLowest,
|
||||
surfaceContainerLow = c.surfaceContainerLow,
|
||||
surfaceContainer = c.surfaceContainer,
|
||||
@@ -161,6 +192,20 @@ private fun lightSchemeOf(c: PaletteColors): ColorScheme =
|
||||
surfaceVariant = c.surfaceVariant,
|
||||
onSurfaceVariant = c.onSurfaceVariant,
|
||||
surfaceTint = c.primary,
|
||||
surfaceDim = c.surfaceDim,
|
||||
surfaceBright = c.surfaceBright,
|
||||
primaryFixed = c.primaryFixed,
|
||||
primaryFixedDim = c.primaryFixedDim,
|
||||
onPrimaryFixed = c.onPrimaryFixed,
|
||||
onPrimaryFixedVariant = c.onPrimaryFixedVariant,
|
||||
secondaryFixed = c.secondaryFixed,
|
||||
secondaryFixedDim = c.secondaryFixedDim,
|
||||
onSecondaryFixed = c.onSecondaryFixed,
|
||||
onSecondaryFixedVariant = c.onSecondaryFixedVariant,
|
||||
tertiaryFixed = c.tertiaryFixed,
|
||||
tertiaryFixedDim = c.tertiaryFixedDim,
|
||||
onTertiaryFixed = c.onTertiaryFixed,
|
||||
onTertiaryFixedVariant = c.onTertiaryFixedVariant,
|
||||
surfaceContainerLowest = c.surfaceContainerLowest,
|
||||
surfaceContainerLow = c.surfaceContainerLow,
|
||||
surfaceContainer = c.surfaceContainer,
|
||||
|
||||
@@ -12,7 +12,8 @@ All theme code is under:
|
||||
|
||||
```
|
||||
app/src/main/java/com/m57/hermescontrol/theme/
|
||||
├── Theme.kt # dispatcher — preset lookup + mode fallback
|
||||
├── Theme.kt # dispatcher — mode fallback + dynamic colors
|
||||
├── ThemeRegistry.kt # single preset/palette registry
|
||||
├── PaletteTemplate.kt # the template — the ONE shape every theme follows
|
||||
├── Color.kt # shared tokens for non-theme code (status fallbacks, code blocks, …)
|
||||
├── HermesStatusColors.kt # semantic status color model (success/warning/error/info + on*)
|
||||
@@ -55,7 +56,14 @@ val MyTheme =
|
||||
The template maps `PaletteColors` into the Material 3 `ColorScheme` slots. The
|
||||
Material `error` slots (`error`, `onError`, `errorContainer`, `onErrorContainer`)
|
||||
are **derived** from the theme's status set — the theme author defines semantic
|
||||
colors once.
|
||||
colors once. `surfaceTint` comes from `primary`. Every other Material color role
|
||||
is required explicitly, including bright/dim surfaces and all fixed accent roles.
|
||||
No role may inherit Material baseline colors. Fixed accent and foreground roles
|
||||
stay identical across a preset’s shipped modes; Fixed/Dim may share an official
|
||||
swatch when the source palette has no accessible tonal ladder.
|
||||
|
||||
Prefer upstream swatches. Document every derived color beside its declaration
|
||||
with the official pairing’s measured contrast and why a derived role is needed.
|
||||
|
||||
## Preset conventions
|
||||
|
||||
@@ -69,8 +77,33 @@ colors once.
|
||||
light text would fail contrast.
|
||||
- **Grayscale status colors** (Monochrome, AMOLED) separate success/warning/
|
||||
error/info by lightness only — consumers must pair with icons or labels.
|
||||
- Every shipped mode's error slot pairs are enforced at **>= 3:1 contrast** by
|
||||
`ThemePaletteTest` — a preset that breaks this fails the unit-test gate.
|
||||
- Normal-text foreground/background pairs, including accent containers, fixed
|
||||
accents, semantic status fills, errors, and the surface ladder, are enforced at **>= 4.5:1 contrast**
|
||||
by `ThemePaletteTest`. The separate full-bleed header guard remains >= 3:1.
|
||||
|
||||
Reusable `StatusBadge` and `SessionLiveStatusIndicator` use a shared
|
||||
`statusBadgeColors` mapping: status fills (`success`, `warning`, `error`, `info`)
|
||||
with their matching `on*` text/icons. `onSuccess` is not a foreground token for
|
||||
`successContainer`. Palette tests exercise this exact renderer mapping in every
|
||||
resolved mode, including fallback modes; neutral badges use Material tokens.
|
||||
|
||||
Enabled Material components also have a component-state contract:
|
||||
- Graphics/boundaries target >= 3:1: switch thumbs/tracks and borders, radio and
|
||||
checkbox states, slider active/inactive tracks, text-field borders, and segmented borders.
|
||||
- Primary/error text and field labels on surface/background target >= 4.5:1.
|
||||
- Segmented content uses the corresponding container foreground at >= 4.5:1.
|
||||
|
||||
`ThemeComponentContrastTest` checks the pinned Material role combinations in every
|
||||
resolved preset/mode. `ThemeComponentGalleryTest` reads the installed Compose
|
||||
public color defaults and verifies rendered pixels for all twelve preset/mode
|
||||
combinations, including focused/error fields. Disabled controls are deliberately
|
||||
excluded from the enabled contrast contract; Material uses opacity to mute them.
|
||||
Settings switches use Material defaults (`primary` track / `onPrimary` thumb),
|
||||
not the reversed `primaryContainer` track / `primary` thumb combination.
|
||||
|
||||
Outlines must contrast against both surface and the unchecked switch track
|
||||
(`surfaceContainerHighest`). Named palettes may need documented derived primary
|
||||
or error tones for direct text; fixed roles still preserve their upstream accents.
|
||||
|
||||
## Theme modes
|
||||
|
||||
@@ -87,37 +120,33 @@ doesn't ship — never to a sibling preset.
|
||||
|
||||
## The dispatcher
|
||||
|
||||
`Theme.kt` is a **pure lookup** — no special-casing. Given a `ThemePreset` and
|
||||
a dark flag, `themeFor()` returns the preset's `ThemePalette`, then the scheme /
|
||||
status are read off it (with the mode fallback above):
|
||||
`ThemeRegistry.kt` maps every `ThemePreset` to one `ThemePalette`. Resolution,
|
||||
UI selection, and palette tests use this registry. An exact enum/registry coverage
|
||||
test rejects missing, duplicate, or reordered entries. Android string resources
|
||||
stay in the UI’s exhaustive label mapping, outside the palette model.
|
||||
|
||||
```kotlin
|
||||
private fun themeFor(preset: ThemePreset): ThemePalette = when (preset) {
|
||||
ThemePreset.DEFAULT -> DefaultTheme
|
||||
ThemePreset.MONOCHROME -> MonochromeTheme
|
||||
// …one line per preset…
|
||||
}
|
||||
```
|
||||
`Theme.kt` reads `preset.palette()` and applies the mode fallback above.
|
||||
|
||||
Dynamic (Material You) color on API 31+ can optionally override the preset scheme when
|
||||
`useDynamicColors = true` (defaults to `false`). Semantic status colors are always resolved from the
|
||||
active preset via `LocalHermesStatusColors`.
|
||||
active preset via `LocalHermesStatusColors`. The preset selector stays enabled
|
||||
and explains that it controls status colors while Material You supplies app colors.
|
||||
|
||||
## Adding a new theme
|
||||
|
||||
1. **Create** `presets/<Name>Scheme.kt` as a template fill (see above) —
|
||||
`buildTheme` for a full theme, `buildThemeDarkOnly` / `buildThemeLightOnly`
|
||||
for single-mode themes. Every status color must be bespoke — no aliasing.
|
||||
2. **Add 1 line** to `themeFor()` in `Theme.kt` + import the new val.
|
||||
2. **Register** the new palette in `ThemeRegistry.kt`.
|
||||
3. **Add the enum entry** `MY_THEME` to `ThemePreset` in `Theme.kt`.
|
||||
4. **Wire the UI**: add the label + selection in
|
||||
4. **Wire the UI**: add the exhaustive label mapping in
|
||||
`ui/settings/components/AppearanceSection.kt` (and any string resource).
|
||||
5. **Verify**:
|
||||
```bash
|
||||
./gradlew ktlintCheck testDebugUnitTest
|
||||
```
|
||||
`ThemePaletteTest` asserts >= 3:1 contrast on every shipped mode's error
|
||||
slot pairs and the ThemeMode invariants — it must stay green.
|
||||
`ThemePaletteTest` checks text contrast, complete Material slot mapping,
|
||||
mode-independent fixed roles, registry coverage, and mode/fallback invariants.
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -10,12 +10,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.m57.hermescontrol.theme.presets.AmoledTheme
|
||||
import com.m57.hermescontrol.theme.presets.CatppuccinTheme
|
||||
import com.m57.hermescontrol.theme.presets.DefaultTheme
|
||||
import com.m57.hermescontrol.theme.presets.GruvboxTheme
|
||||
import com.m57.hermescontrol.theme.presets.MonochromeTheme
|
||||
import com.m57.hermescontrol.theme.presets.NordTheme
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@@ -28,20 +23,6 @@ val LocalThemePreference = compositionLocalOf { ThemePreference.SYSTEM }
|
||||
val LocalThemePreset = compositionLocalOf { ThemePreset.DEFAULT }
|
||||
val LocalChatFontScale = compositionLocalOf { 1.0f }
|
||||
|
||||
/**
|
||||
* The 6 preset themes — one file each, all built from the same
|
||||
* [PaletteTemplate] shape (see `PaletteTemplate.kt`).
|
||||
*/
|
||||
private fun themeFor(preset: ThemePreset): ThemePalette =
|
||||
when (preset) {
|
||||
ThemePreset.DEFAULT -> DefaultTheme
|
||||
ThemePreset.MONOCHROME -> MonochromeTheme
|
||||
ThemePreset.GRUVBOX -> GruvboxTheme
|
||||
ThemePreset.CATPPUCCIN -> CatppuccinTheme
|
||||
ThemePreset.AMOLED -> AmoledTheme
|
||||
ThemePreset.NORD -> NordTheme
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Material 3 [ColorScheme] for a preset + dark flag.
|
||||
*
|
||||
@@ -53,7 +34,7 @@ internal fun resolveColorScheme(
|
||||
preset: ThemePreset,
|
||||
darkTheme: Boolean,
|
||||
): ColorScheme {
|
||||
val theme = themeFor(preset)
|
||||
val theme = preset.palette()
|
||||
// DefaultTheme is FULL — its scheme is never null for either mode.
|
||||
return theme.schemeFor(darkTheme) ?: requireNotNull(DefaultTheme.schemeFor(darkTheme))
|
||||
}
|
||||
@@ -66,7 +47,7 @@ internal fun resolveStatusColors(
|
||||
preset: ThemePreset,
|
||||
darkTheme: Boolean,
|
||||
): HermesStatusColors {
|
||||
val theme = themeFor(preset)
|
||||
val theme = preset.palette()
|
||||
// DefaultTheme is FULL — its status colors are never null for either mode.
|
||||
return theme.statusFor(darkTheme) ?: requireNotNull(DefaultTheme.statusFor(darkTheme))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
surfaceVariant = AmoledContainerHigh,
|
||||
onSurfaceVariant = AmoledTextDim,
|
||||
surfaceDim = AmoledFloor,
|
||||
surfaceBright = AmoledContainerHighest,
|
||||
primaryFixed = AmoledTextBright,
|
||||
primaryFixedDim = AmoledTextBright,
|
||||
onPrimaryFixed = AmoledFloor,
|
||||
onPrimaryFixedVariant = AmoledFloor,
|
||||
secondaryFixed = AmoledSecondary,
|
||||
secondaryFixedDim = AmoledSecondary,
|
||||
onSecondaryFixed = AmoledFloor,
|
||||
onSecondaryFixedVariant = AmoledFloor,
|
||||
tertiaryFixed = AmoledTertiary,
|
||||
tertiaryFixedDim = AmoledTertiary,
|
||||
onTertiaryFixed = AmoledFloor,
|
||||
onTertiaryFixedVariant = AmoledFloor,
|
||||
surfaceContainerLowest = AmoledFloor,
|
||||
surfaceContainerLow = AmoledFloor,
|
||||
surfaceContainer = AmoledContainer,
|
||||
|
||||
@@ -19,7 +19,6 @@ private val MochaRed = Color(0xFFF38BA8)
|
||||
private val MochaText = Color(0xFFCDD6F4)
|
||||
private val MochaSubtext1 = Color(0xFFBAC2DE)
|
||||
private val MochaSubtext0 = Color(0xFFA6ADC8)
|
||||
private val MochaOverlay0 = Color(0xFF6C7086)
|
||||
private val MochaSurface1 = Color(0xFF45475A)
|
||||
private val MochaSurface0 = Color(0xFF313244)
|
||||
private val MochaBase = Color(0xFF1E1E2E)
|
||||
@@ -31,24 +30,33 @@ private val MochaCrust = Color(0xFF11111B)
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private val LatteMauve = Color(0xFF8839EF)
|
||||
|
||||
// Derived role, not an upstream Catppuccin swatch: Latte Text/Pink is 3.02:1
|
||||
// and Text/Green is 2.39:1. Deeper ink reaches 5.88:1 and 4.65:1 respectively.
|
||||
private val LatteDeepInk = Color(0xFF202334)
|
||||
|
||||
// Derived role, not an upstream Catppuccin swatch: Latte Base/Blue is only 4.34:1.
|
||||
// White/Blue reaches 4.94:1 for normal text.
|
||||
private val LatteOnBlue = Color(0xFFFFFFFF)
|
||||
private val LatteBlue = Color(0xFF1E66F5)
|
||||
private val LattePink = Color(0xFFEA76CB)
|
||||
private val LatteGreen = Color(0xFF40A02B)
|
||||
private val LatteYellow = Color(0xFFDF8E1D)
|
||||
private val LatteRed = Color(0xFFD20F39)
|
||||
private val LatteText = Color(0xFF4C4F69)
|
||||
|
||||
// Official Latte Subtext0 keeps borders visible against Surface0 (3.20:1).
|
||||
private val LatteSubtext0 = Color(0xFF6C6F85)
|
||||
private val LatteOverlay0 = Color(0xFF9CA0B0)
|
||||
private val LatteText = Color(0xFF4C4F69)
|
||||
private val LatteSurface1 = Color(0xFFBCC0CC)
|
||||
private val LatteSurface0 = Color(0xFFCCD0DA)
|
||||
private val LatteBase = Color(0xFFEFF1F5)
|
||||
private val LatteMantle = Color(0xFFE6E9EF)
|
||||
private val LatteCrust = Color(0xFFDCE0E8)
|
||||
|
||||
// Bespoke tint used only for the light primary container — not part of
|
||||
// the official Catppuccin swatch set, kept local to this file.
|
||||
private val LattePrimaryContainer = Color(0xFFE6CAFA)
|
||||
private val LatteOnPrimaryContainer = Color(0xFF380075)
|
||||
// Regression theme-contract: fixed accents use Mocha in both modes. Fixed/Dim
|
||||
// share official accents rather than inventing tonal steps; on-roles use Crust.
|
||||
// Latte surface containers stop at Surface0 so normal text stays >= 4.5:1.
|
||||
// Upstream: https://github.com/catppuccin/palette/blob/main/palette.json
|
||||
|
||||
/** Catppuccin theme — Mocha (dark) / Latte (light). */
|
||||
val CatppuccinTheme =
|
||||
@@ -73,6 +81,20 @@ val CatppuccinTheme =
|
||||
onSurface = MochaText,
|
||||
surfaceVariant = MochaSurface0,
|
||||
onSurfaceVariant = MochaSubtext0,
|
||||
surfaceDim = MochaBase,
|
||||
surfaceBright = MochaSurface1,
|
||||
primaryFixed = MochaMauve,
|
||||
primaryFixedDim = MochaMauve,
|
||||
onPrimaryFixed = MochaCrust,
|
||||
onPrimaryFixedVariant = MochaCrust,
|
||||
secondaryFixed = MochaBlue,
|
||||
secondaryFixedDim = MochaBlue,
|
||||
onSecondaryFixed = MochaCrust,
|
||||
onSecondaryFixedVariant = MochaCrust,
|
||||
tertiaryFixed = MochaPink,
|
||||
tertiaryFixedDim = MochaPink,
|
||||
onTertiaryFixed = MochaCrust,
|
||||
onTertiaryFixedVariant = MochaCrust,
|
||||
surfaceContainerLowest = MochaCrust,
|
||||
surfaceContainerLow = MochaMantle,
|
||||
surfaceContainer = MochaBase,
|
||||
@@ -81,7 +103,7 @@ val CatppuccinTheme =
|
||||
inverseSurface = MochaText,
|
||||
inverseOnSurface = MochaBase,
|
||||
inversePrimary = LatteMauve,
|
||||
outline = MochaOverlay0,
|
||||
outline = MochaSubtext0,
|
||||
outlineVariant = MochaSurface1,
|
||||
scrim = MochaCrust,
|
||||
status =
|
||||
@@ -105,48 +127,62 @@ val CatppuccinTheme =
|
||||
PaletteColors(
|
||||
primary = LatteMauve,
|
||||
onPrimary = LatteBase,
|
||||
primaryContainer = LattePrimaryContainer,
|
||||
onPrimaryContainer = LatteOnPrimaryContainer,
|
||||
primaryContainer = LatteBase,
|
||||
onPrimaryContainer = LatteMauve,
|
||||
secondary = LatteBlue,
|
||||
onSecondary = LatteBase,
|
||||
onSecondary = LatteOnBlue,
|
||||
secondaryContainer = LatteMantle,
|
||||
onSecondaryContainer = LatteText,
|
||||
tertiary = LattePink,
|
||||
onTertiary = LatteBase,
|
||||
tertiaryContainer = LatteSurface1,
|
||||
onTertiary = LatteDeepInk,
|
||||
tertiaryContainer = LatteSurface0,
|
||||
onTertiaryContainer = LatteText,
|
||||
background = LatteBase,
|
||||
onBackground = LatteText,
|
||||
surface = LatteBase,
|
||||
onSurface = LatteText,
|
||||
surfaceVariant = LatteSurface0,
|
||||
onSurfaceVariant = LatteSubtext0,
|
||||
onSurfaceVariant = LatteText,
|
||||
surfaceDim = LatteSurface0,
|
||||
surfaceBright = LatteBase,
|
||||
primaryFixed = MochaMauve,
|
||||
primaryFixedDim = MochaMauve,
|
||||
onPrimaryFixed = MochaCrust,
|
||||
onPrimaryFixedVariant = MochaCrust,
|
||||
secondaryFixed = MochaBlue,
|
||||
secondaryFixedDim = MochaBlue,
|
||||
onSecondaryFixed = MochaCrust,
|
||||
onSecondaryFixedVariant = MochaCrust,
|
||||
tertiaryFixed = MochaPink,
|
||||
tertiaryFixedDim = MochaPink,
|
||||
onTertiaryFixed = MochaCrust,
|
||||
onTertiaryFixedVariant = MochaCrust,
|
||||
surfaceContainerLowest = LatteCrust,
|
||||
surfaceContainerLow = LatteMantle,
|
||||
surfaceContainer = LatteBase,
|
||||
surfaceContainerHigh = LatteSurface0,
|
||||
surfaceContainerHighest = LatteSurface1,
|
||||
surfaceContainerHighest = LatteSurface0,
|
||||
inverseSurface = LatteText,
|
||||
inverseOnSurface = LatteBase,
|
||||
inversePrimary = MochaMauve,
|
||||
outline = LatteOverlay0,
|
||||
outline = LatteSubtext0,
|
||||
outlineVariant = LatteSurface1,
|
||||
scrim = LatteCrust,
|
||||
status =
|
||||
HermesStatusColors(
|
||||
success = LatteGreen,
|
||||
successContainer = LatteSurface0,
|
||||
onSuccess = LatteBase,
|
||||
onSuccess = LatteDeepInk,
|
||||
warning = LatteYellow,
|
||||
warningContainer = LatteSurface0,
|
||||
onWarning = LatteBase,
|
||||
onWarning = LatteDeepInk,
|
||||
error = LatteRed,
|
||||
errorContainer = LatteSurface0,
|
||||
onError = LatteBase,
|
||||
onErrorContainer = LatteRed,
|
||||
onErrorContainer = LatteText,
|
||||
info = LatteBlue,
|
||||
infoContainer = LatteSurface0,
|
||||
onInfo = LatteBase,
|
||||
onInfo = LatteOnBlue,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -73,6 +73,20 @@ val DefaultTheme =
|
||||
onSurface = SlateDarkFg1,
|
||||
surfaceVariant = SlateDarkBg1,
|
||||
onSurfaceVariant = SlateDarkFg3,
|
||||
surfaceDim = SlateDarkBg0,
|
||||
surfaceBright = SlateDarkBg4,
|
||||
primaryFixed = SlateBrightTeal,
|
||||
primaryFixedDim = SlateBrightTeal,
|
||||
onPrimaryFixed = SlateDarkBg0,
|
||||
onPrimaryFixedVariant = SlateDarkBg0,
|
||||
secondaryFixed = SlateBrightCyan,
|
||||
secondaryFixedDim = SlateBrightCyan,
|
||||
onSecondaryFixed = SlateDarkBg0,
|
||||
onSecondaryFixedVariant = SlateDarkBg0,
|
||||
tertiaryFixed = SlateBrightBlue,
|
||||
tertiaryFixedDim = SlateBrightBlue,
|
||||
onTertiaryFixed = SlateDarkBg0,
|
||||
onTertiaryFixedVariant = SlateDarkBg0,
|
||||
surfaceContainerLowest = SlateDarkBg0,
|
||||
surfaceContainerLow = SlateDarkBg1,
|
||||
surfaceContainer = SlateDarkBg2,
|
||||
@@ -121,6 +135,20 @@ val DefaultTheme =
|
||||
onSurface = SlateLightFg1,
|
||||
surfaceVariant = SlateLightBg1,
|
||||
onSurfaceVariant = SlateLightFg3,
|
||||
surfaceDim = SlateLightBg4,
|
||||
surfaceBright = SlateLightBg0,
|
||||
primaryFixed = SlateBrightTeal,
|
||||
primaryFixedDim = SlateBrightTeal,
|
||||
onPrimaryFixed = SlateDarkBg0,
|
||||
onPrimaryFixedVariant = SlateDarkBg0,
|
||||
secondaryFixed = SlateBrightCyan,
|
||||
secondaryFixedDim = SlateBrightCyan,
|
||||
onSecondaryFixed = SlateDarkBg0,
|
||||
onSecondaryFixedVariant = SlateDarkBg0,
|
||||
tertiaryFixed = SlateBrightBlue,
|
||||
tertiaryFixedDim = SlateBrightBlue,
|
||||
onTertiaryFixed = SlateDarkBg0,
|
||||
onTertiaryFixedVariant = SlateDarkBg0,
|
||||
surfaceContainerLowest = SlateLightBg0,
|
||||
surfaceContainerLow = SlateLightBg1,
|
||||
surfaceContainer = SlateLightBg2,
|
||||
@@ -129,7 +157,7 @@ val DefaultTheme =
|
||||
inverseSurface = SlateLightFg1,
|
||||
inverseOnSurface = SlateLightBg0,
|
||||
inversePrimary = SlateBrightTeal,
|
||||
outline = SlateGray,
|
||||
outline = SlateLightFg3,
|
||||
outlineVariant = SlateLightBg3,
|
||||
scrim = SlateLightBg0,
|
||||
status =
|
||||
|
||||
@@ -9,18 +9,20 @@ import com.m57.hermescontrol.theme.buildTheme
|
||||
// Gruvbox Dark (medium contrast) — named swatches
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Official hard-contrast bg0; medium bg0/red only reaches 4.29:1.
|
||||
private val GruvboxDarkInk = Color(0xFF1D2021)
|
||||
private val GruvboxDarkBg0 = Color(0xFF282828)
|
||||
private val GruvboxDarkBg1 = Color(0xFF3C3836)
|
||||
private val GruvboxDarkBg2 = Color(0xFF504945)
|
||||
private val GruvboxDarkBg3 = Color(0xFF665C54)
|
||||
private val GruvboxDarkBg4 = Color(0xFF7C6F64)
|
||||
private val GruvboxDarkFg1 = Color(0xFFEBDBB2)
|
||||
private val GruvboxDarkFg2 = Color(0xFFD5C4A1)
|
||||
private val GruvboxDarkFg3 = Color(0xFFBDAE93)
|
||||
private val GruvboxGray = Color(0xFF928374) // same neutral gray in both modes
|
||||
|
||||
// "Bright" accents — the variants gruvbox uses against a dark background.
|
||||
private val GruvboxBrightRed = Color(0xFFFB4934)
|
||||
// Derived role, not an upstream swatch: bright red/bg0 is only 4.29:1.
|
||||
// Lighter red reaches 4.92:1 for error text and field labels on bg0.
|
||||
private val GruvboxErrorText = Color(0xFFFF5F4F)
|
||||
private val GruvboxBrightGreen = Color(0xFFB8BB26)
|
||||
private val GruvboxBrightYellow = Color(0xFFFABD2F)
|
||||
private val GruvboxBrightBlue = Color(0xFF83A598)
|
||||
@@ -35,7 +37,6 @@ private val GruvboxLightBg0 = Color(0xFFFBF1C7)
|
||||
private val GruvboxLightBg1 = Color(0xFFEBDBB2)
|
||||
private val GruvboxLightBg2 = Color(0xFFD5C4A1)
|
||||
private val GruvboxLightBg3 = Color(0xFFBDAE93)
|
||||
private val GruvboxLightBg4 = Color(0xFFA89984)
|
||||
private val GruvboxLightFg1 = Color(0xFF3C3836)
|
||||
private val GruvboxLightFg2 = Color(0xFF504945)
|
||||
private val GruvboxLightFg3 = Color(0xFF665C54)
|
||||
@@ -43,28 +44,38 @@ private val GruvboxLightFg3 = Color(0xFF665C54)
|
||||
// "Faded" accents — the muted variants gruvbox uses against a light
|
||||
// background, so contrast holds up without the neon dark-mode saturation.
|
||||
private val GruvboxFadedRed = Color(0xFF9D0006)
|
||||
|
||||
// Derived roles, not upstream Gruvbox swatches: light bg0/faded green is 4.29:1;
|
||||
// white/green reaches 4.86:1. Hard dark bg0/faded yellow is below 4.5:1;
|
||||
// deeper neutral ink/yellow reaches 4.97:1.
|
||||
private val GruvboxOnFadedGreen = Color(0xFFFFFFFF)
|
||||
private val GruvboxOnFadedYellow = Color(0xFF121212)
|
||||
private val GruvboxFadedGreen = Color(0xFF79740E)
|
||||
private val GruvboxFadedYellow = Color(0xFFB57614)
|
||||
private val GruvboxFadedBlue = Color(0xFF076678)
|
||||
private val GruvboxFadedPurple = Color(0xFF8F3F71)
|
||||
private val GruvboxFadedOrange = Color(0xFFAF3A03)
|
||||
|
||||
/** Gruvbox theme — bright accents on dark bg0–bg4, faded accents on light bg0–bg4. */
|
||||
// Regression theme-contract: fixed roles reuse bright accents in both modes;
|
||||
// Fixed/Dim share a swatch. Containers stop at bg3 for >= 4.5:1 body text.
|
||||
// Upstream: https://github.com/morhetz/gruvbox/blob/master/colors/gruvbox.vim
|
||||
|
||||
/** Gruvbox theme — bright accents on dark bg0–bg3, faded accents on light bg0–bg3. */
|
||||
val GruvboxTheme =
|
||||
buildTheme(
|
||||
dark =
|
||||
PaletteColors(
|
||||
primary = GruvboxBrightOrange,
|
||||
onPrimary = GruvboxDarkBg0,
|
||||
primaryContainer = GruvboxDarkBg4,
|
||||
primaryContainer = GruvboxDarkBg2,
|
||||
onPrimaryContainer = GruvboxDarkFg1,
|
||||
secondary = GruvboxBrightBlue,
|
||||
onSecondary = GruvboxDarkBg0,
|
||||
secondaryContainer = GruvboxDarkBg3,
|
||||
secondaryContainer = GruvboxDarkBg2,
|
||||
onSecondaryContainer = GruvboxDarkFg2,
|
||||
tertiary = GruvboxBrightPurple,
|
||||
onTertiary = GruvboxDarkBg0,
|
||||
tertiaryContainer = GruvboxDarkBg4,
|
||||
tertiaryContainer = GruvboxDarkBg2,
|
||||
onTertiaryContainer = GruvboxDarkFg1,
|
||||
background = GruvboxDarkBg0,
|
||||
onBackground = GruvboxDarkFg1,
|
||||
@@ -72,15 +83,29 @@ val GruvboxTheme =
|
||||
onSurface = GruvboxDarkFg1,
|
||||
surfaceVariant = GruvboxDarkBg1,
|
||||
onSurfaceVariant = GruvboxDarkFg3,
|
||||
surfaceDim = GruvboxDarkBg0,
|
||||
surfaceBright = GruvboxDarkBg3,
|
||||
primaryFixed = GruvboxBrightOrange,
|
||||
primaryFixedDim = GruvboxBrightOrange,
|
||||
onPrimaryFixed = GruvboxDarkBg0,
|
||||
onPrimaryFixedVariant = GruvboxDarkBg0,
|
||||
secondaryFixed = GruvboxBrightBlue,
|
||||
secondaryFixedDim = GruvboxBrightBlue,
|
||||
onSecondaryFixed = GruvboxDarkBg0,
|
||||
onSecondaryFixedVariant = GruvboxDarkBg0,
|
||||
tertiaryFixed = GruvboxBrightPurple,
|
||||
tertiaryFixedDim = GruvboxBrightPurple,
|
||||
onTertiaryFixed = GruvboxDarkBg0,
|
||||
onTertiaryFixedVariant = GruvboxDarkBg0,
|
||||
surfaceContainerLowest = GruvboxDarkBg0,
|
||||
surfaceContainerLow = GruvboxDarkBg1,
|
||||
surfaceContainer = GruvboxDarkBg2,
|
||||
surfaceContainerHigh = GruvboxDarkBg3,
|
||||
surfaceContainerHighest = GruvboxDarkBg4,
|
||||
surfaceContainerHighest = GruvboxDarkBg3,
|
||||
inverseSurface = GruvboxDarkFg1,
|
||||
inverseOnSurface = GruvboxDarkBg0,
|
||||
inversePrimary = GruvboxFadedOrange,
|
||||
outline = GruvboxGray,
|
||||
outline = GruvboxDarkFg2,
|
||||
outlineVariant = GruvboxDarkBg3,
|
||||
scrim = GruvboxDarkBg0,
|
||||
status =
|
||||
@@ -91,9 +116,9 @@ val GruvboxTheme =
|
||||
warning = GruvboxBrightYellow,
|
||||
warningContainer = GruvboxDarkBg3,
|
||||
onWarning = GruvboxDarkBg0,
|
||||
error = GruvboxBrightRed,
|
||||
error = GruvboxErrorText,
|
||||
errorContainer = GruvboxDarkBg3,
|
||||
onError = GruvboxDarkBg0,
|
||||
onError = GruvboxDarkInk,
|
||||
onErrorContainer = GruvboxDarkFg1,
|
||||
info = GruvboxBrightBlue,
|
||||
infoContainer = GruvboxDarkBg3,
|
||||
@@ -104,15 +129,15 @@ val GruvboxTheme =
|
||||
PaletteColors(
|
||||
primary = GruvboxFadedOrange,
|
||||
onPrimary = GruvboxLightBg0,
|
||||
primaryContainer = GruvboxLightBg4,
|
||||
primaryContainer = GruvboxLightBg2,
|
||||
onPrimaryContainer = GruvboxLightFg1,
|
||||
secondary = GruvboxFadedBlue,
|
||||
onSecondary = GruvboxLightBg0,
|
||||
secondaryContainer = GruvboxLightBg3,
|
||||
secondaryContainer = GruvboxLightBg2,
|
||||
onSecondaryContainer = GruvboxLightFg2,
|
||||
tertiary = GruvboxFadedPurple,
|
||||
onTertiary = GruvboxLightBg0,
|
||||
tertiaryContainer = GruvboxLightBg4,
|
||||
tertiaryContainer = GruvboxLightBg2,
|
||||
onTertiaryContainer = GruvboxLightFg1,
|
||||
background = GruvboxLightBg0,
|
||||
onBackground = GruvboxLightFg1,
|
||||
@@ -120,29 +145,43 @@ val GruvboxTheme =
|
||||
onSurface = GruvboxLightFg1,
|
||||
surfaceVariant = GruvboxLightBg1,
|
||||
onSurfaceVariant = GruvboxLightFg3,
|
||||
surfaceDim = GruvboxLightBg3,
|
||||
surfaceBright = GruvboxLightBg0,
|
||||
primaryFixed = GruvboxBrightOrange,
|
||||
primaryFixedDim = GruvboxBrightOrange,
|
||||
onPrimaryFixed = GruvboxDarkBg0,
|
||||
onPrimaryFixedVariant = GruvboxDarkBg0,
|
||||
secondaryFixed = GruvboxBrightBlue,
|
||||
secondaryFixedDim = GruvboxBrightBlue,
|
||||
onSecondaryFixed = GruvboxDarkBg0,
|
||||
onSecondaryFixedVariant = GruvboxDarkBg0,
|
||||
tertiaryFixed = GruvboxBrightPurple,
|
||||
tertiaryFixedDim = GruvboxBrightPurple,
|
||||
onTertiaryFixed = GruvboxDarkBg0,
|
||||
onTertiaryFixedVariant = GruvboxDarkBg0,
|
||||
surfaceContainerLowest = GruvboxLightBg0,
|
||||
surfaceContainerLow = GruvboxLightBg1,
|
||||
surfaceContainer = GruvboxLightBg2,
|
||||
surfaceContainerHigh = GruvboxLightBg3,
|
||||
surfaceContainerHighest = GruvboxLightBg4,
|
||||
surfaceContainerHighest = GruvboxLightBg3,
|
||||
inverseSurface = GruvboxLightFg1,
|
||||
inverseOnSurface = GruvboxLightBg0,
|
||||
inversePrimary = GruvboxBrightOrange,
|
||||
outline = GruvboxGray,
|
||||
outline = GruvboxLightFg2,
|
||||
outlineVariant = GruvboxLightBg3,
|
||||
scrim = GruvboxLightBg0,
|
||||
status =
|
||||
HermesStatusColors(
|
||||
success = GruvboxFadedGreen,
|
||||
successContainer = GruvboxLightBg3,
|
||||
onSuccess = GruvboxLightBg0,
|
||||
onSuccess = GruvboxOnFadedGreen,
|
||||
warning = GruvboxFadedYellow,
|
||||
warningContainer = GruvboxLightBg3,
|
||||
onWarning = GruvboxLightBg0,
|
||||
onWarning = GruvboxOnFadedYellow,
|
||||
error = GruvboxFadedRed,
|
||||
errorContainer = GruvboxLightBg3,
|
||||
onError = GruvboxLightBg0,
|
||||
onErrorContainer = GruvboxFadedRed,
|
||||
onErrorContainer = GruvboxLightFg1,
|
||||
info = GruvboxFadedBlue,
|
||||
infoContainer = GruvboxLightBg3,
|
||||
onInfo = GruvboxLightBg0,
|
||||
|
||||
@@ -32,7 +32,6 @@ private val MonoLightBg = Color(0xFFFAFAFA) // background, surface, containerLow
|
||||
private val MonoLightContainer = Color(0xFFF0F0F0) // surfaceContainer
|
||||
private val MonoLightContainerHigh = Color(0xFFE0E0E0) // surfaceContainerHigh, surfaceVariant, status containers
|
||||
private val MonoLightContainerHighest = Color(0xFFD1D1D1) // surfaceContainerHighest, primaryContainer
|
||||
private val MonoLightOutline = Color(0xFF808080)
|
||||
private val MonoLightOutlineVariant = Color(0xFFCCCCCC)
|
||||
private val MonoLightTextDim = Color(0xFF4D4D4D) // onSurfaceVariant
|
||||
private val MonoLightTextBright = Color(0xFF121212) // onBackground/onSurface/inverseSurface/primary/error
|
||||
@@ -71,6 +70,20 @@ val MonochromeTheme =
|
||||
onSurface = MonoDarkTextBright,
|
||||
surfaceVariant = MonoDarkContainerHigh,
|
||||
onSurfaceVariant = MonoDarkTextDim,
|
||||
surfaceDim = MonoDarkBg,
|
||||
surfaceBright = MonoDarkContainerHighest,
|
||||
primaryFixed = MonoDarkTextBright,
|
||||
primaryFixedDim = MonoDarkTextBright,
|
||||
onPrimaryFixed = MonoDarkBg,
|
||||
onPrimaryFixedVariant = MonoDarkBg,
|
||||
secondaryFixed = MonoDarkSecondary,
|
||||
secondaryFixedDim = MonoDarkSecondary,
|
||||
onSecondaryFixed = MonoDarkBg,
|
||||
onSecondaryFixedVariant = MonoDarkBg,
|
||||
tertiaryFixed = MonoDarkTertiary,
|
||||
tertiaryFixedDim = MonoDarkTertiary,
|
||||
onTertiaryFixed = MonoDarkBg,
|
||||
onTertiaryFixedVariant = MonoDarkBg,
|
||||
surfaceContainerLowest = MonoDarkFloor,
|
||||
surfaceContainerLow = MonoDarkBg,
|
||||
surfaceContainer = MonoDarkContainer,
|
||||
@@ -112,13 +125,27 @@ val MonochromeTheme =
|
||||
tertiary = MonoLightTertiary,
|
||||
onTertiary = MonoLightBg,
|
||||
tertiaryContainer = MonoLightContainerHigh,
|
||||
onTertiaryContainer = MonoLightTertiary,
|
||||
onTertiaryContainer = MonoLightSecondary,
|
||||
background = MonoLightBg,
|
||||
onBackground = MonoLightTextBright,
|
||||
surface = MonoLightBg,
|
||||
onSurface = MonoLightTextBright,
|
||||
surfaceVariant = MonoLightContainerHigh,
|
||||
onSurfaceVariant = MonoLightTextDim,
|
||||
surfaceDim = MonoLightContainerHighest,
|
||||
surfaceBright = MonoLightBg,
|
||||
primaryFixed = MonoDarkTextBright,
|
||||
primaryFixedDim = MonoDarkTextBright,
|
||||
onPrimaryFixed = MonoDarkBg,
|
||||
onPrimaryFixedVariant = MonoDarkBg,
|
||||
secondaryFixed = MonoDarkSecondary,
|
||||
secondaryFixedDim = MonoDarkSecondary,
|
||||
onSecondaryFixed = MonoDarkBg,
|
||||
onSecondaryFixedVariant = MonoDarkBg,
|
||||
tertiaryFixed = MonoDarkTertiary,
|
||||
tertiaryFixedDim = MonoDarkTertiary,
|
||||
onTertiaryFixed = MonoDarkBg,
|
||||
onTertiaryFixedVariant = MonoDarkBg,
|
||||
surfaceContainerLowest = MonoLightCeiling,
|
||||
surfaceContainerLow = MonoLightBg,
|
||||
surfaceContainer = MonoLightContainer,
|
||||
@@ -127,7 +154,7 @@ val MonochromeTheme =
|
||||
inverseSurface = MonoLightTextBright,
|
||||
inverseOnSurface = MonoLightBg,
|
||||
inversePrimary = MonoDarkTextBright,
|
||||
outline = MonoLightOutline,
|
||||
outline = MonoLightTertiary,
|
||||
outlineVariant = MonoLightOutlineVariant,
|
||||
scrim = Color.Black,
|
||||
status =
|
||||
@@ -144,7 +171,7 @@ val MonochromeTheme =
|
||||
onErrorContainer = MonoLightTextBright,
|
||||
info = MonoLightInfo,
|
||||
infoContainer = MonoLightContainerHigh,
|
||||
onInfo = MonoLightBg,
|
||||
onInfo = MonoLightTextBright,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -11,9 +11,12 @@ import com.m57.hermescontrol.theme.buildTheme
|
||||
// supply the surfaces; Frost (7–10) and Aurora (11–15) supply the
|
||||
// accents. Unlike Gruvbox, Nord defines one accent set — not a
|
||||
// separate bright/faded pair per mode — so the same accent swatches
|
||||
// are reused in both palettes below.
|
||||
// are reused for fixed roles; primary/error need documented accessible tones.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
// Derived role, not an upstream Nord swatch: nord0/red is 3.05:1 and
|
||||
// nord0/purple is 4.41:1. Darker Polar Night ink meets 4.5:1 on both.
|
||||
private val NordPolarInk = Color(0xFF0F1218)
|
||||
private val Nord0 = Color(0xFF2E3440) // Polar Night — darkest
|
||||
private val Nord1 = Color(0xFF3B4252)
|
||||
private val Nord2 = Color(0xFF434C5E)
|
||||
@@ -23,11 +26,23 @@ private val Nord5 = Color(0xFFE5E9F0)
|
||||
private val Nord6 = Color(0xFFECEFF4) // Snow Storm — lightest
|
||||
private val Nord8 = Color(0xFF88C0D0) // Frost — primary accent
|
||||
private val Nord9 = Color(0xFF81A1C1) // Frost — secondary / info
|
||||
private val Nord11 = Color(0xFFBF616A) // Aurora red — error
|
||||
|
||||
// Derived roles, not upstream Nord swatches. Nord10/Snow Storm is only 3.50:1;
|
||||
// deeper Frost ink reaches 4.92:1 on nord6 for primary text/focus labels.
|
||||
private val NordFrostInk = Color(0xFF486991)
|
||||
|
||||
// Official Aurora red/nord0 is 3.05:1; a brighter red reaches 4.67:1.
|
||||
private val NordErrorBright = Color(0xFFDE858F)
|
||||
|
||||
// Official Aurora red/nord6 is 3.55:1; deeper red reaches 5.69:1.
|
||||
private val NordErrorInk = Color(0xFFA53547)
|
||||
private val Nord13 = Color(0xFFEBCB8B) // Aurora yellow — warning
|
||||
private val Nord14 = Color(0xFFA3BE8C) // Aurora green — success
|
||||
private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary
|
||||
|
||||
// Fixed/Dim reuse each official accent in both modes (no invented tonal ladder).
|
||||
// Upstream: https://www.nordtheme.com/docs/colors-and-palettes/
|
||||
|
||||
/**
|
||||
* Nord theme.
|
||||
*
|
||||
@@ -36,12 +51,11 @@ private val Nord15 = Color(0xFFB48EAD) // Aurora purple — tertiary
|
||||
* tiers on each side share a value (surfaceContainerHigh ==
|
||||
* surfaceContainerHighest) rather than inventing an off-palette hex.
|
||||
*
|
||||
* Frost/Aurora accents are pastel by design, so onPrimary/onSecondary/
|
||||
* onTertiary/status "on" colors use Polar Night (dark text) in *both*
|
||||
* modes — reusing Snow Storm for light-mode "on" text (the convention in
|
||||
* Pastel Frost/Aurora fills use dark ink in both modes — reusing
|
||||
* Snow Storm for light-mode "on" text (the convention in
|
||||
* the Catppuccin/Gruvbox presets) would fail contrast against colors like
|
||||
* Aurora yellow, which stays light even without a separate light-mode
|
||||
* variant.
|
||||
* variant. Light primary/error use Snow Storm ink on their darker fills.
|
||||
*/
|
||||
val NordTheme =
|
||||
buildTheme(
|
||||
@@ -56,7 +70,7 @@ val NordTheme =
|
||||
secondaryContainer = Nord2,
|
||||
onSecondaryContainer = Nord4,
|
||||
tertiary = Nord15,
|
||||
onTertiary = Nord0,
|
||||
onTertiary = NordPolarInk,
|
||||
tertiaryContainer = Nord3,
|
||||
onTertiaryContainer = Nord6,
|
||||
background = Nord0,
|
||||
@@ -65,6 +79,20 @@ val NordTheme =
|
||||
onSurface = Nord6,
|
||||
surfaceVariant = Nord1,
|
||||
onSurfaceVariant = Nord4,
|
||||
surfaceDim = Nord0,
|
||||
surfaceBright = Nord3,
|
||||
primaryFixed = Nord8,
|
||||
primaryFixedDim = Nord8,
|
||||
onPrimaryFixed = Nord0,
|
||||
onPrimaryFixedVariant = Nord0,
|
||||
secondaryFixed = Nord9,
|
||||
secondaryFixedDim = Nord9,
|
||||
onSecondaryFixed = Nord0,
|
||||
onSecondaryFixedVariant = Nord0,
|
||||
tertiaryFixed = Nord15,
|
||||
tertiaryFixedDim = Nord15,
|
||||
onTertiaryFixed = NordPolarInk,
|
||||
onTertiaryFixedVariant = NordPolarInk,
|
||||
surfaceContainerLowest = Nord0,
|
||||
surfaceContainerLow = Nord1,
|
||||
surfaceContainer = Nord2,
|
||||
@@ -72,8 +100,8 @@ val NordTheme =
|
||||
surfaceContainerHighest = Nord3,
|
||||
inverseSurface = Nord6,
|
||||
inverseOnSurface = Nord0,
|
||||
inversePrimary = Nord8,
|
||||
outline = Nord3,
|
||||
inversePrimary = NordFrostInk,
|
||||
outline = Nord4,
|
||||
outlineVariant = Nord2,
|
||||
scrim = Nord0,
|
||||
status =
|
||||
@@ -84,9 +112,9 @@ val NordTheme =
|
||||
warning = Nord13,
|
||||
warningContainer = Nord2,
|
||||
onWarning = Nord0,
|
||||
error = Nord11,
|
||||
error = NordErrorBright,
|
||||
errorContainer = Nord2,
|
||||
onError = Nord0,
|
||||
onError = NordPolarInk,
|
||||
onErrorContainer = Nord6,
|
||||
info = Nord9,
|
||||
infoContainer = Nord2,
|
||||
@@ -95,8 +123,8 @@ val NordTheme =
|
||||
),
|
||||
light =
|
||||
PaletteColors(
|
||||
primary = Nord8,
|
||||
onPrimary = Nord0,
|
||||
primary = NordFrostInk,
|
||||
onPrimary = Nord6,
|
||||
primaryContainer = Nord4,
|
||||
onPrimaryContainer = Nord0,
|
||||
secondary = Nord9,
|
||||
@@ -104,7 +132,7 @@ val NordTheme =
|
||||
secondaryContainer = Nord5,
|
||||
onSecondaryContainer = Nord1,
|
||||
tertiary = Nord15,
|
||||
onTertiary = Nord0,
|
||||
onTertiary = NordPolarInk,
|
||||
tertiaryContainer = Nord4,
|
||||
onTertiaryContainer = Nord0,
|
||||
background = Nord6,
|
||||
@@ -113,6 +141,20 @@ val NordTheme =
|
||||
onSurface = Nord0,
|
||||
surfaceVariant = Nord5,
|
||||
onSurfaceVariant = Nord2,
|
||||
surfaceDim = Nord4,
|
||||
surfaceBright = Nord6,
|
||||
primaryFixed = Nord8,
|
||||
primaryFixedDim = Nord8,
|
||||
onPrimaryFixed = Nord0,
|
||||
onPrimaryFixedVariant = Nord0,
|
||||
secondaryFixed = Nord9,
|
||||
secondaryFixedDim = Nord9,
|
||||
onSecondaryFixed = Nord0,
|
||||
onSecondaryFixedVariant = Nord0,
|
||||
tertiaryFixed = Nord15,
|
||||
tertiaryFixedDim = Nord15,
|
||||
onTertiaryFixed = NordPolarInk,
|
||||
onTertiaryFixedVariant = NordPolarInk,
|
||||
surfaceContainerLowest = Nord6,
|
||||
surfaceContainerLow = Nord5,
|
||||
surfaceContainer = Nord5,
|
||||
@@ -132,10 +174,10 @@ val NordTheme =
|
||||
warning = Nord13,
|
||||
warningContainer = Nord5,
|
||||
onWarning = Nord0,
|
||||
error = Nord11,
|
||||
error = NordErrorInk,
|
||||
errorContainer = Nord5,
|
||||
onError = Nord0,
|
||||
onErrorContainer = Nord11,
|
||||
onError = Nord6,
|
||||
onErrorContainer = Nord0,
|
||||
info = Nord9,
|
||||
infoContainer = Nord5,
|
||||
onInfo = Nord0,
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
|
||||
@@ -81,28 +82,8 @@ fun StatusBadge(
|
||||
val spacing = LocalSpacing.current
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
val (bgColor, fgColor) =
|
||||
when (status) {
|
||||
StatusBadgeType.SUCCESS -> {
|
||||
statusColors.successContainer to statusColors.success
|
||||
}
|
||||
|
||||
StatusBadgeType.WARNING -> {
|
||||
statusColors.warningContainer to statusColors.warning
|
||||
}
|
||||
|
||||
StatusBadgeType.ERROR -> {
|
||||
statusColors.errorContainer to statusColors.error
|
||||
}
|
||||
|
||||
StatusBadgeType.INFO -> {
|
||||
statusColors.infoContainer to statusColors.info
|
||||
}
|
||||
|
||||
StatusBadgeType.NEUTRAL -> {
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh to
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
}
|
||||
statusBadgeColors(status, statusColors)
|
||||
?: (MaterialTheme.colorScheme.surfaceContainerHigh to MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
@@ -135,6 +116,20 @@ fun StatusBadge(
|
||||
|
||||
enum class StatusBadgeType { SUCCESS, WARNING, ERROR, INFO, NEUTRAL }
|
||||
|
||||
/** Exact fill/text pair shared by status badges and live-session indicators. */
|
||||
internal fun statusBadgeColors(
|
||||
status: StatusBadgeType,
|
||||
colors: HermesStatusColors,
|
||||
): Pair<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 ────────────────────
|
||||
|
||||
@Composable
|
||||
|
||||
+8
-14
@@ -25,6 +25,8 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||
import com.m57.hermescontrol.theme.LocalSpacing
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.common.statusBadgeColors
|
||||
|
||||
@Composable
|
||||
fun SessionLiveStatusIndicator(
|
||||
@@ -35,27 +37,26 @@ fun SessionLiveStatusIndicator(
|
||||
val spacing = LocalSpacing.current
|
||||
val statusColors = LocalHermesStatusColors.current
|
||||
|
||||
val (bgColor, fgColor, textRes, descRes) =
|
||||
val (badgeType, textRes, descRes) =
|
||||
when (liveStatus) {
|
||||
SessionLiveStatus.WORKING -> {
|
||||
Tuple4(
|
||||
statusColors.successContainer,
|
||||
statusColors.success,
|
||||
Triple(
|
||||
StatusBadgeType.SUCCESS,
|
||||
R.string.sessions_live_status_running,
|
||||
R.string.sessions_live_status_running_desc,
|
||||
)
|
||||
}
|
||||
|
||||
SessionLiveStatus.WAITING -> {
|
||||
Tuple4(
|
||||
statusColors.warningContainer,
|
||||
statusColors.warning,
|
||||
Triple(
|
||||
StatusBadgeType.WARNING,
|
||||
R.string.sessions_live_status_waiting,
|
||||
R.string.sessions_live_status_waiting_desc,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val (bgColor, fgColor) = requireNotNull(statusBadgeColors(badgeType, statusColors))
|
||||
val text = stringResource(textRes)
|
||||
val description = stringResource(descRes)
|
||||
|
||||
@@ -103,10 +104,3 @@ fun SessionLiveStatusIndicator(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class Tuple4<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.OutlinedButton
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -270,11 +269,6 @@ private fun ReleaseCandidateRow(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
enabled = enabled,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
+27
-69
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.settings.components
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -21,7 +22,6 @@ import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -41,6 +41,7 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import com.m57.hermescontrol.theme.ThemeRegistry
|
||||
import com.m57.hermescontrol.ui.chat.label
|
||||
import com.m57.hermescontrol.ui.settings.SectionCard
|
||||
import kotlin.math.abs
|
||||
@@ -55,6 +56,7 @@ internal fun AppearanceSection(
|
||||
themePreset: ThemePreset,
|
||||
onThemePresetChange: (ThemePreset) -> Unit,
|
||||
) {
|
||||
val dynamicAvailable = useDynamicColors && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
SectionCard {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_item_theme),
|
||||
@@ -106,11 +108,6 @@ internal fun AppearanceSection(
|
||||
Switch(
|
||||
checked = useDynamicColors,
|
||||
onCheckedChange = onUseDynamicColorsChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,72 +119,30 @@ internal fun AppearanceSection(
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
if (dynamicAvailable) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_desc_theme_preset_dynamic),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
var presetsExpanded by remember { mutableStateOf(false) }
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
OutlinedButton(
|
||||
onClick = { presetsExpanded = true },
|
||||
enabled = !useDynamicColors,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
when (themePreset) {
|
||||
ThemePreset.DEFAULT -> stringResource(R.string.theme_preset_default)
|
||||
ThemePreset.MONOCHROME -> stringResource(R.string.theme_preset_monochrome)
|
||||
ThemePreset.GRUVBOX -> stringResource(R.string.theme_preset_gruvbox)
|
||||
ThemePreset.CATPPUCCIN -> stringResource(R.string.theme_preset_catppuccin)
|
||||
ThemePreset.AMOLED -> stringResource(R.string.theme_preset_amoled)
|
||||
ThemePreset.NORD -> stringResource(R.string.theme_preset_nord)
|
||||
},
|
||||
)
|
||||
Text(themePreset.label())
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = presetsExpanded,
|
||||
onDismissRequest = { presetsExpanded = false },
|
||||
modifier = Modifier.fillMaxWidth(0.85f),
|
||||
) {
|
||||
ThemePreset.entries.forEach { preset ->
|
||||
ThemeRegistry.forEach { (preset, _) ->
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
when (preset) {
|
||||
ThemePreset.DEFAULT -> {
|
||||
stringResource(
|
||||
R.string.theme_preset_default,
|
||||
)
|
||||
}
|
||||
|
||||
ThemePreset.MONOCHROME -> {
|
||||
stringResource(
|
||||
R.string.theme_preset_monochrome,
|
||||
)
|
||||
}
|
||||
|
||||
ThemePreset.GRUVBOX -> {
|
||||
stringResource(
|
||||
R.string.theme_preset_gruvbox,
|
||||
)
|
||||
}
|
||||
|
||||
ThemePreset.CATPPUCCIN -> {
|
||||
stringResource(
|
||||
R.string.theme_preset_catppuccin,
|
||||
)
|
||||
}
|
||||
|
||||
ThemePreset.AMOLED -> {
|
||||
stringResource(
|
||||
R.string.theme_preset_amoled,
|
||||
)
|
||||
}
|
||||
|
||||
ThemePreset.NORD -> {
|
||||
stringResource(
|
||||
R.string.theme_preset_nord,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
text = { Text(preset.label()) },
|
||||
onClick = {
|
||||
onThemePresetChange(preset)
|
||||
presetsExpanded = false
|
||||
@@ -199,6 +154,19 @@ internal fun AppearanceSection(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemePreset.label(): String =
|
||||
stringResource(
|
||||
when (this) {
|
||||
ThemePreset.DEFAULT -> R.string.theme_preset_default
|
||||
ThemePreset.MONOCHROME -> R.string.theme_preset_monochrome
|
||||
ThemePreset.GRUVBOX -> R.string.theme_preset_gruvbox
|
||||
ThemePreset.CATPPUCCIN -> R.string.theme_preset_catppuccin
|
||||
ThemePreset.AMOLED -> R.string.theme_preset_amoled
|
||||
ThemePreset.NORD -> R.string.theme_preset_nord
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
internal fun ChatSection(
|
||||
typingEffectEnabled: Boolean,
|
||||
@@ -485,11 +453,6 @@ internal fun ChatSection(
|
||||
checked = showModelProvider,
|
||||
onCheckedChange = onShowModelProviderChange,
|
||||
modifier = Modifier.testTag("settings_show_model_provider"),
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -516,11 +479,6 @@ internal fun ChatSection(
|
||||
Switch(
|
||||
checked = typingEffectEnabled,
|
||||
onCheckedChange = onTypingEffectEnabledChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
@@ -50,11 +49,6 @@ internal fun BehaviorSection(
|
||||
Switch(
|
||||
checked = autoReconnect,
|
||||
onCheckedChange = onAutoReconnectChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,11 +77,6 @@ internal fun BehaviorSection(
|
||||
Switch(
|
||||
checked = keepConnectedInBackground,
|
||||
onCheckedChange = onKeepConnectedInBackgroundChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -116,11 +105,6 @@ internal fun BehaviorSection(
|
||||
Switch(
|
||||
checked = restoreLastSession,
|
||||
onCheckedChange = onRestoreLastSessionChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1845,4 +1845,5 @@
|
||||
<string name="mcp_tab_catalog">Catalog</string>
|
||||
<string name="mcp_servers_catalog_empty_title">No catalog entries</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>
|
||||
@@ -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
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import com.m57.hermescontrol.theme.presets.AmoledTheme
|
||||
import com.m57.hermescontrol.theme.presets.CatppuccinTheme
|
||||
import com.m57.hermescontrol.theme.presets.DefaultTheme
|
||||
import com.m57.hermescontrol.theme.presets.GruvboxTheme
|
||||
import com.m57.hermescontrol.theme.presets.MonochromeTheme
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.common.statusBadgeColors
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
/**
|
||||
* Guards the palette template's color invariants:
|
||||
* 1. Every shipped error slot pair meets >= 3:1 WCAG contrast.
|
||||
* 1. Normal-text pairs meet >= 4.5:1 contrast in every resolved mode.
|
||||
* 2. ThemeMode combinations are validated at construction time.
|
||||
* 3. One-mode themes resolve and fall back to the default theme correctly.
|
||||
* 3. Every Material role is explicitly mapped; fixed roles are mode-independent.
|
||||
* 4. One-mode themes resolve and fall back to the default theme correctly.
|
||||
*/
|
||||
class ThemePaletteTest {
|
||||
private val themes =
|
||||
listOf(
|
||||
ThemePreset.DEFAULT to DefaultTheme,
|
||||
ThemePreset.MONOCHROME to MonochromeTheme,
|
||||
ThemePreset.GRUVBOX to GruvboxTheme,
|
||||
ThemePreset.CATPPUCCIN to CatppuccinTheme,
|
||||
ThemePreset.AMOLED to AmoledTheme,
|
||||
)
|
||||
@Test
|
||||
fun registryCoversEveryPresetExactlyOnce() {
|
||||
assertEquals(ThemePreset.entries, ThemeRegistry.map { it.preset })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun errorSlotPairsMeetContrastInEveryShippedMode() {
|
||||
themes.forEach { (preset, theme) ->
|
||||
fun textSlotPairsMeetContrastInEveryResolvedMode() {
|
||||
ThemeRegistry.forEach { (preset, _) ->
|
||||
listOf(true, false).forEach { dark ->
|
||||
val scheme = theme.schemeFor(dark) ?: return@forEach
|
||||
assertTrue(
|
||||
"$preset dark=$dark onError/error contrast must be >= 3:1",
|
||||
contrast(scheme.onError, scheme.error) >= 3f,
|
||||
)
|
||||
assertTrue(
|
||||
"$preset dark=$dark onErrorContainer/errorContainer contrast must be >= 3:1",
|
||||
contrast(scheme.onErrorContainer, scheme.errorContainer) >= 3f,
|
||||
)
|
||||
val c = resolveColorScheme(preset, dark)
|
||||
val pairs =
|
||||
listOf(
|
||||
"onPrimary/primary" to (c.onPrimary to c.primary),
|
||||
"onPrimaryContainer/primaryContainer" to (c.onPrimaryContainer to c.primaryContainer),
|
||||
"onSecondary/secondary" to (c.onSecondary to c.secondary),
|
||||
"onSecondaryContainer/secondaryContainer" to (c.onSecondaryContainer to c.secondaryContainer),
|
||||
"onTertiary/tertiary" to (c.onTertiary to c.tertiary),
|
||||
"onTertiaryContainer/tertiaryContainer" to (c.onTertiaryContainer to c.tertiaryContainer),
|
||||
"onBackground/background" to (c.onBackground to c.background),
|
||||
"onSurface/surface" to (c.onSurface to c.surface),
|
||||
"onSurfaceVariant/surfaceVariant" to (c.onSurfaceVariant to c.surfaceVariant),
|
||||
"onSurface/surfaceDim" to (c.onSurface to c.surfaceDim),
|
||||
"onSurface/surfaceBright" to (c.onSurface to c.surfaceBright),
|
||||
"onSurface/surfaceContainerLowest" to (c.onSurface to c.surfaceContainerLowest),
|
||||
"onSurface/surfaceContainerLow" to (c.onSurface to c.surfaceContainerLow),
|
||||
"onSurface/surfaceContainer" to (c.onSurface to c.surfaceContainer),
|
||||
"onSurface/surfaceContainerHigh" to (c.onSurface to c.surfaceContainerHigh),
|
||||
"onSurface/surfaceContainerHighest" to (c.onSurface to c.surfaceContainerHighest),
|
||||
"inverseOnSurface/inverseSurface" to (c.inverseOnSurface to c.inverseSurface),
|
||||
"onError/error" to (c.onError to c.error),
|
||||
"onErrorContainer/errorContainer" to (c.onErrorContainer to c.errorContainer),
|
||||
"onPrimaryFixed/primaryFixed" to (c.onPrimaryFixed to c.primaryFixed),
|
||||
"onPrimaryFixedVariant/primaryFixed" to (c.onPrimaryFixedVariant to c.primaryFixed),
|
||||
"onPrimaryFixed/primaryFixedDim" to (c.onPrimaryFixed to c.primaryFixedDim),
|
||||
"onPrimaryFixedVariant/primaryFixedDim" to (c.onPrimaryFixedVariant to c.primaryFixedDim),
|
||||
"onSecondaryFixed/secondaryFixed" to (c.onSecondaryFixed to c.secondaryFixed),
|
||||
"onSecondaryFixedVariant/secondaryFixed" to (c.onSecondaryFixedVariant to c.secondaryFixed),
|
||||
"onSecondaryFixed/secondaryFixedDim" to (c.onSecondaryFixed to c.secondaryFixedDim),
|
||||
"onSecondaryFixedVariant/secondaryFixedDim" to
|
||||
(c.onSecondaryFixedVariant to c.secondaryFixedDim),
|
||||
"onTertiaryFixed/tertiaryFixed" to (c.onTertiaryFixed to c.tertiaryFixed),
|
||||
"onTertiaryFixedVariant/tertiaryFixed" to (c.onTertiaryFixedVariant to c.tertiaryFixed),
|
||||
"onTertiaryFixed/tertiaryFixedDim" to (c.onTertiaryFixed to c.tertiaryFixedDim),
|
||||
"onTertiaryFixedVariant/tertiaryFixedDim" to (c.onTertiaryFixedVariant to c.tertiaryFixedDim),
|
||||
)
|
||||
pairs.forEach { (name, colors) ->
|
||||
val ratio = contrast(colors.first, colors.second)
|
||||
assertTrue("$preset dark=$dark $name contrast $ratio must be >= 4.5:1", ratio >= 4.5f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renderedStatusBadgePairsMeetContrastInEveryResolvedMode() {
|
||||
ThemeRegistry.forEach { (preset, _) ->
|
||||
listOf(true, false).forEach { dark ->
|
||||
val colors = resolveStatusColors(preset, dark)
|
||||
StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL }.forEach { type ->
|
||||
// PR #1417: test the mapping consumed by both reusable badge renderers.
|
||||
val (background, foreground) = requireNotNull(statusBadgeColors(type, colors))
|
||||
val ratio = contrast(foreground, background)
|
||||
assertTrue("$preset dark=$dark $type badge contrast $ratio must be >= 4.5:1", ratio >= 4.5f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusBadgesUseMatchingStatusFillsAndOnColors() {
|
||||
val c = dummyColors().status
|
||||
assertEquals(c.success to c.onSuccess, statusBadgeColors(StatusBadgeType.SUCCESS, c))
|
||||
assertEquals(c.warning to c.onWarning, statusBadgeColors(StatusBadgeType.WARNING, c))
|
||||
assertEquals(c.error to c.onError, statusBadgeColors(StatusBadgeType.ERROR, c))
|
||||
assertEquals(c.info to c.onInfo, statusBadgeColors(StatusBadgeType.INFO, c))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fixedRolesDoNotChangeBetweenShippedModes() {
|
||||
ThemeRegistry.forEach { (preset, theme) ->
|
||||
val dark = theme.darkScheme
|
||||
val light = theme.lightScheme
|
||||
if (dark != null && light != null) {
|
||||
val darkRoles = colorFields(dark).filterKeys { "Fixed" in it }
|
||||
val lightRoles = colorFields(light).filterKeys { "Fixed" in it }
|
||||
assertEquals("$preset fixed roles must be mode-independent", darkRoles, lightRoles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyMaterialColorRoleIsMappedFromThePalette() {
|
||||
// Distinct sentinel values catch swapped roles and accidental Material defaults.
|
||||
val colors = dummyColors()
|
||||
val expected = colorFields(colors).toMutableMap()
|
||||
expected["surfaceTint"] = expected.getValue("primary")
|
||||
expected.putAll(
|
||||
colorFields(colors.status).filterKeys {
|
||||
it in
|
||||
setOf(
|
||||
"error",
|
||||
"onError",
|
||||
"errorContainer",
|
||||
"onErrorContainer",
|
||||
)
|
||||
},
|
||||
)
|
||||
val theme = buildTheme(colors, colors)
|
||||
listOf(theme.darkScheme, theme.lightScheme).forEach { scheme ->
|
||||
assertEquals(expected, colorFields(requireNotNull(scheme)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorFields(value: Any): Map<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
|
||||
* on the screen background (HermesScaffold containerColor = background),
|
||||
* so the full-bleed text pairs must hold against [ColorScheme.background]:
|
||||
* - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text
|
||||
* - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI
|
||||
* (The header deliberately avoids `primary`: Nord's light mode reuses its
|
||||
* pastel Frost accent as primary, which cannot reach 3:1 on a light
|
||||
* background.)
|
||||
* Iterates ThemePreset.entries directly so EVERY shipped preset (incl.
|
||||
* NORD, which the [themes] list above predates) is covered.
|
||||
* Header text uses onSurfaceVariant independently of the theme's accent.
|
||||
* The registry covers every shipped preset and its fallback mode.
|
||||
*/
|
||||
@Test
|
||||
fun fullBleedTextPairsMeetContrastInEveryShippedMode() {
|
||||
ThemePreset.entries.forEach { preset ->
|
||||
ThemeRegistry.forEach { (preset, _) ->
|
||||
listOf(true, false).forEach { dark ->
|
||||
val scheme = resolveColorScheme(preset, darkTheme = dark) ?: return@forEach
|
||||
val scheme = resolveColorScheme(preset, darkTheme = dark)
|
||||
assertTrue(
|
||||
"$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)",
|
||||
contrast(scheme.onSurface, scheme.background) >= 4.5f,
|
||||
@@ -84,18 +181,18 @@ class ThemePaletteTest {
|
||||
|
||||
@Test
|
||||
fun everyPresetResolvesItsDeclaredModes() {
|
||||
themes.forEach { (preset, theme) ->
|
||||
ThemeRegistry.forEach { (preset, theme) ->
|
||||
listOf(true, false).forEach { dark ->
|
||||
if (theme.schemeFor(dark) != null) {
|
||||
assertTrue(
|
||||
"$preset dark=$dark scheme must resolve",
|
||||
resolveColorScheme(preset, darkTheme = dark) != null,
|
||||
)
|
||||
assertTrue(
|
||||
"$preset dark=$dark status must resolve",
|
||||
resolveStatusColors(preset, darkTheme = dark) != null,
|
||||
)
|
||||
}
|
||||
assertSame(
|
||||
"$preset dark=$dark must resolve its palette or Default fallback",
|
||||
theme.schemeFor(dark) ?: DefaultTheme.schemeFor(dark),
|
||||
resolveColorScheme(preset, dark),
|
||||
)
|
||||
assertSame(
|
||||
"$preset dark=$dark must resolve its status or Default fallback",
|
||||
theme.statusFor(dark) ?: DefaultTheme.statusFor(dark),
|
||||
resolveStatusColors(preset, dark),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,50 +266,64 @@ class ThemePaletteTest {
|
||||
|
||||
private fun dummyColors(): PaletteColors =
|
||||
PaletteColors(
|
||||
primary = Color.White,
|
||||
onPrimary = Color.Black,
|
||||
primaryContainer = Color.White,
|
||||
onPrimaryContainer = Color.Black,
|
||||
secondary = Color.White,
|
||||
onSecondary = Color.Black,
|
||||
secondaryContainer = Color.White,
|
||||
onSecondaryContainer = Color.Black,
|
||||
tertiary = Color.White,
|
||||
onTertiary = Color.Black,
|
||||
tertiaryContainer = Color.White,
|
||||
onTertiaryContainer = Color.Black,
|
||||
background = Color.White,
|
||||
onBackground = Color.Black,
|
||||
surface = Color.White,
|
||||
onSurface = Color.Black,
|
||||
surfaceVariant = Color.White,
|
||||
onSurfaceVariant = Color.Black,
|
||||
surfaceContainerLowest = Color.White,
|
||||
surfaceContainerLow = Color.White,
|
||||
surfaceContainer = Color.White,
|
||||
surfaceContainerHigh = Color.White,
|
||||
surfaceContainerHighest = Color.White,
|
||||
inverseSurface = Color.Black,
|
||||
inverseOnSurface = Color.White,
|
||||
inversePrimary = Color.Black,
|
||||
outline = Color.Gray,
|
||||
outlineVariant = Color.Gray,
|
||||
scrim = Color.Black,
|
||||
primary = Color(0xFF000000L + 1),
|
||||
onPrimary = Color(0xFF000000L + 2),
|
||||
primaryContainer = Color(0xFF000000L + 3),
|
||||
onPrimaryContainer = Color(0xFF000000L + 4),
|
||||
secondary = Color(0xFF000000L + 5),
|
||||
onSecondary = Color(0xFF000000L + 6),
|
||||
secondaryContainer = Color(0xFF000000L + 7),
|
||||
onSecondaryContainer = Color(0xFF000000L + 8),
|
||||
tertiary = Color(0xFF000000L + 9),
|
||||
onTertiary = Color(0xFF000000L + 10),
|
||||
tertiaryContainer = Color(0xFF000000L + 11),
|
||||
onTertiaryContainer = Color(0xFF000000L + 12),
|
||||
background = Color(0xFF000000L + 13),
|
||||
onBackground = Color(0xFF000000L + 14),
|
||||
surface = Color(0xFF000000L + 15),
|
||||
onSurface = Color(0xFF000000L + 16),
|
||||
surfaceVariant = Color(0xFF000000L + 17),
|
||||
onSurfaceVariant = Color(0xFF000000L + 18),
|
||||
surfaceDim = Color(0xFF000000L + 19),
|
||||
surfaceBright = Color(0xFF000000L + 20),
|
||||
primaryFixed = Color(0xFF000000L + 21),
|
||||
primaryFixedDim = Color(0xFF000000L + 22),
|
||||
onPrimaryFixed = Color(0xFF000000L + 23),
|
||||
onPrimaryFixedVariant = Color(0xFF000000L + 24),
|
||||
secondaryFixed = Color(0xFF000000L + 25),
|
||||
secondaryFixedDim = Color(0xFF000000L + 26),
|
||||
onSecondaryFixed = Color(0xFF000000L + 27),
|
||||
onSecondaryFixedVariant = Color(0xFF000000L + 28),
|
||||
tertiaryFixed = Color(0xFF000000L + 29),
|
||||
tertiaryFixedDim = Color(0xFF000000L + 30),
|
||||
onTertiaryFixed = Color(0xFF000000L + 31),
|
||||
onTertiaryFixedVariant = Color(0xFF000000L + 32),
|
||||
surfaceContainerLowest = Color(0xFF000000L + 33),
|
||||
surfaceContainerLow = Color(0xFF000000L + 34),
|
||||
surfaceContainer = Color(0xFF000000L + 35),
|
||||
surfaceContainerHigh = Color(0xFF000000L + 36),
|
||||
surfaceContainerHighest = Color(0xFF000000L + 37),
|
||||
inverseSurface = Color(0xFF000000L + 38),
|
||||
inverseOnSurface = Color(0xFF000000L + 39),
|
||||
inversePrimary = Color(0xFF000000L + 40),
|
||||
outline = Color(0xFF000000L + 41),
|
||||
outlineVariant = Color(0xFF000000L + 42),
|
||||
scrim = Color(0xFF000000L + 43),
|
||||
status =
|
||||
HermesStatusColors(
|
||||
success = Color.Green,
|
||||
successContainer = Color.White,
|
||||
onSuccess = Color.Black,
|
||||
warning = Color.Yellow,
|
||||
warningContainer = Color.White,
|
||||
onWarning = Color.Black,
|
||||
error = Color.Red,
|
||||
errorContainer = Color.White,
|
||||
onError = Color.Black,
|
||||
onErrorContainer = Color.Black,
|
||||
info = Color.Blue,
|
||||
infoContainer = Color.White,
|
||||
onInfo = Color.Black,
|
||||
success = Color(0xFF000000L + 100),
|
||||
successContainer = Color(0xFF000000L + 101),
|
||||
onSuccess = Color(0xFF000000L + 102),
|
||||
warning = Color(0xFF000000L + 103),
|
||||
warningContainer = Color(0xFF000000L + 104),
|
||||
onWarning = Color(0xFF000000L + 105),
|
||||
error = Color(0xFF000000L + 106),
|
||||
errorContainer = Color(0xFF000000L + 107),
|
||||
onError = Color(0xFF000000L + 108),
|
||||
onErrorContainer = Color(0xFF000000L + 109),
|
||||
info = Color(0xFF000000L + 110),
|
||||
infoContainer = Color(0xFF000000L + 111),
|
||||
onInfo = Color(0xFF000000L + 112),
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user