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

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

No files matched your search

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