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 |
|
| 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 }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
|
* 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
|
||||||
|
|||||||
+8
-14
@@ -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,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+27
-69
@@ -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,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user