* feat(chat): ChatStyle preference type + store field + AuthManager (issue #866) * feat(settings): chat style toggle in Appearance (issue #866) * feat(chat): plumb ChatStyle into ChatUiState + refreshSettings (issue #866) * feat(chat): full-bleed turn grouping (issue #866) * feat(chat): full-bleed agent message renderer (issue #866) * feat(chat): full-bleed compact tool rows + system events (issue #866) * feat(chat): full-bleed chat list renderer + streaming (issue #866) * feat(chat): wire full-bleed renderer behind chat style setting (issue #866) * test(theme): full-bleed contrast gate across all presets (issue #866) Gate caught NORD light primary/background < 3:1 — header role label switched to onSurfaceVariant (meta token, correct for header + passes in every preset; NORD's pastel Frost accent can't reach 3:1 on light). * test(chat): full-bleed renderer coverage (issue #866) * style(chat): ktlint formatting pass on full-bleed renderer (issue #866) * fix(chat): eagerly capture per-item state in full-bleed list (issue #866) LazyColumn item{} lambdas run at composition time, so loop locals read inside them saw final values: turn header never rendered (firstProseSeen always true at compose) and milestones misindexed. Capture showTurnHeader + milestone as immutable vals during the DSL loop. Caught by fresh-context review (decompiled Compose foundation to confirm item composition timing). * test(chat): stub AuthManager.getChatStyle in mockkObject setups (issue #866) refreshSettings()/loadSettings() now read AuthManager.getChatStyle(); the four test files that mockkObject(AuthManager) with explicit stubs had no stub for the new method, so mockk fell through to the real object and threw 'AuthManager not initialized'. Full suite: 953/953 passing, same shape as pristine origin/main. * feat(settings): move chat style toggle from Appearance to Chat section * feat(chat): hoist reasoning above tool rows in full-bleed turns Tool rows stream in before the answer message, so the turn rendered tools above the reasoning card. The turn's reasoning now renders at the top of the agent turn, with tool rows and prose underneath. Streaming is folded into the turn model (groupIntoTurnsWithStreaming) so the hoist works live while the answer is still streaming, and the detached streaming item is gone (which also fixed the duplicate turn-header quirk for streaming continuations). * feat(chat): cap reasoning card height + long-press to copy Expanded trace caps at 40% of the screen with internal scroll (Show full lifts the cap); long-press anywhere copies the reasoning. * feat(chat): blinking typing caret while streaming * feat(chat): make full-bleed the default chat style * feat(chat): remove thinking dots, show agent header above reasoning --------- Co-authored-by: M57 <hy4ri@users.noreply.github.com>
219 lines
8.6 KiB
Kotlin
219 lines
8.6 KiB
Kotlin
package com.m57.hermescontrol.theme
|
|
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.luminance
|
|
import com.m57.hermescontrol.theme.presets.AmoledTheme
|
|
import com.m57.hermescontrol.theme.presets.CatppuccinTheme
|
|
import com.m57.hermescontrol.theme.presets.DefaultTheme
|
|
import com.m57.hermescontrol.theme.presets.GruvboxTheme
|
|
import com.m57.hermescontrol.theme.presets.MonochromeTheme
|
|
import org.junit.Assert.assertEquals
|
|
import org.junit.Assert.assertThrows
|
|
import org.junit.Assert.assertTrue
|
|
import org.junit.Test
|
|
|
|
/**
|
|
* Guards the palette template's color invariants:
|
|
* 1. Every shipped error slot pair meets >= 3:1 WCAG contrast.
|
|
* 2. ThemeMode combinations are validated at construction time.
|
|
* 3. One-mode themes resolve and fall back to the default theme correctly.
|
|
*/
|
|
class ThemePaletteTest {
|
|
private val themes =
|
|
listOf(
|
|
ThemePreset.DEFAULT to DefaultTheme,
|
|
ThemePreset.MONOCHROME to MonochromeTheme,
|
|
ThemePreset.GRUVBOX to GruvboxTheme,
|
|
ThemePreset.CATPPUCCIN to CatppuccinTheme,
|
|
ThemePreset.AMOLED to AmoledTheme,
|
|
)
|
|
|
|
@Test
|
|
fun errorSlotPairsMeetContrastInEveryShippedMode() {
|
|
themes.forEach { (preset, theme) ->
|
|
listOf(true, false).forEach { dark ->
|
|
val scheme = theme.schemeFor(dark) ?: return@forEach
|
|
assertTrue(
|
|
"$preset dark=$dark onError/error contrast must be >= 3:1",
|
|
contrast(scheme.onError, scheme.error) >= 3f,
|
|
)
|
|
assertTrue(
|
|
"$preset dark=$dark onErrorContainer/errorContainer contrast must be >= 3:1",
|
|
contrast(scheme.onErrorContainer, scheme.errorContainer) >= 3f,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Full-bleed chat renderer gate (issue #866): agent prose renders directly
|
|
* on the screen background (HermesScaffold containerColor = background),
|
|
* so the full-bleed text pairs must hold against [ColorScheme.background]:
|
|
* - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text
|
|
* - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI
|
|
* (The header deliberately avoids `primary`: Nord's light mode reuses its
|
|
* pastel Frost accent as primary, which cannot reach 3:1 on a light
|
|
* background.)
|
|
* Iterates ThemePreset.entries directly so EVERY shipped preset (incl.
|
|
* NORD, which the [themes] list above predates) is covered.
|
|
*/
|
|
@Test
|
|
fun fullBleedTextPairsMeetContrastInEveryShippedMode() {
|
|
ThemePreset.entries.forEach { preset ->
|
|
listOf(true, false).forEach { dark ->
|
|
val scheme = resolveColorScheme(preset, darkTheme = dark) ?: return@forEach
|
|
assertTrue(
|
|
"$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)",
|
|
contrast(scheme.onSurface, scheme.background) >= 4.5f,
|
|
)
|
|
assertTrue(
|
|
"$preset dark=$dark onSurfaceVariant/background contrast must be >= 3:1 (full-bleed header)",
|
|
contrast(scheme.onSurfaceVariant, scheme.background) >= 3f,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun amoledShipsDarkOnly() {
|
|
assertTrue("AMOLED must ship a dark scheme", AmoledTheme.darkScheme != null)
|
|
assertTrue("AMOLED must not ship a light scheme", AmoledTheme.lightScheme == null)
|
|
assertTrue("AMOLED must ship dark status colors", AmoledTheme.darkStatus != null)
|
|
assertTrue("AMOLED must not ship light status colors", AmoledTheme.lightStatus == null)
|
|
}
|
|
|
|
@Test
|
|
fun everyPresetResolvesItsDeclaredModes() {
|
|
themes.forEach { (preset, theme) ->
|
|
listOf(true, false).forEach { dark ->
|
|
if (theme.schemeFor(dark) != null) {
|
|
assertTrue(
|
|
"$preset dark=$dark scheme must resolve",
|
|
resolveColorScheme(preset, darkTheme = dark) != null,
|
|
)
|
|
assertTrue(
|
|
"$preset dark=$dark status must resolve",
|
|
resolveStatusColors(preset, darkTheme = dark) != null,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test
|
|
fun amoledLightModeFallsBackToDefaultLight() {
|
|
val fallbackScheme = resolveColorScheme(ThemePreset.AMOLED, darkTheme = false)
|
|
val defaultLight = DefaultTheme.lightScheme!!
|
|
assertEquals(
|
|
"AMOLED light scheme must fall back to Default light background",
|
|
defaultLight.background,
|
|
fallbackScheme.background,
|
|
)
|
|
|
|
val fallbackStatus = resolveStatusColors(ThemePreset.AMOLED, darkTheme = false)
|
|
val defaultLightStatus = DefaultTheme.lightStatus!!
|
|
assertEquals(
|
|
"AMOLED light status must fall back to Default light success",
|
|
defaultLightStatus.success,
|
|
fallbackStatus.success,
|
|
)
|
|
|
|
// AMOLED dark resolves to its own palette, not the default.
|
|
val ownDark = AmoledTheme.darkScheme!!
|
|
assertEquals(
|
|
"AMOLED dark scheme must be its own",
|
|
ownDark.background,
|
|
resolveColorScheme(ThemePreset.AMOLED, darkTheme = true).background,
|
|
)
|
|
}
|
|
|
|
@Test
|
|
fun themePaletteRejectsIllegalModeCombinations() {
|
|
val colors = dummyColors()
|
|
assertThrows(IllegalArgumentException::class.java) {
|
|
ThemePalette(ThemeMode.FULL, null, null)
|
|
}
|
|
assertThrows(IllegalArgumentException::class.java) {
|
|
ThemePalette(ThemeMode.FULL, colors, null)
|
|
}
|
|
assertThrows(IllegalArgumentException::class.java) {
|
|
ThemePalette(ThemeMode.FULL, null, colors)
|
|
}
|
|
assertThrows(IllegalArgumentException::class.java) {
|
|
ThemePalette(ThemeMode.DARK_ONLY, null, null)
|
|
}
|
|
assertThrows(IllegalArgumentException::class.java) {
|
|
ThemePalette(ThemeMode.DARK_ONLY, null, colors)
|
|
}
|
|
assertThrows(IllegalArgumentException::class.java) {
|
|
ThemePalette(ThemeMode.LIGHT_ONLY, null, null)
|
|
}
|
|
assertThrows(IllegalArgumentException::class.java) {
|
|
ThemePalette(ThemeMode.LIGHT_ONLY, colors, null)
|
|
}
|
|
|
|
// Valid combinations must construct without throwing.
|
|
ThemePalette(ThemeMode.FULL, colors, colors)
|
|
ThemePalette(ThemeMode.DARK_ONLY, colors, null)
|
|
ThemePalette(ThemeMode.LIGHT_ONLY, null, colors)
|
|
}
|
|
|
|
private fun contrast(
|
|
a: Color,
|
|
b: Color,
|
|
): Float {
|
|
val lighter = maxOf(a.luminance(), b.luminance())
|
|
val darker = minOf(a.luminance(), b.luminance())
|
|
return (lighter + 0.05f) / (darker + 0.05f)
|
|
}
|
|
|
|
private fun dummyColors(): PaletteColors =
|
|
PaletteColors(
|
|
primary = Color.White,
|
|
onPrimary = Color.Black,
|
|
primaryContainer = Color.White,
|
|
onPrimaryContainer = Color.Black,
|
|
secondary = Color.White,
|
|
onSecondary = Color.Black,
|
|
secondaryContainer = Color.White,
|
|
onSecondaryContainer = Color.Black,
|
|
tertiary = Color.White,
|
|
onTertiary = Color.Black,
|
|
tertiaryContainer = Color.White,
|
|
onTertiaryContainer = Color.Black,
|
|
background = Color.White,
|
|
onBackground = Color.Black,
|
|
surface = Color.White,
|
|
onSurface = Color.Black,
|
|
surfaceVariant = Color.White,
|
|
onSurfaceVariant = Color.Black,
|
|
surfaceContainerLowest = Color.White,
|
|
surfaceContainerLow = Color.White,
|
|
surfaceContainer = Color.White,
|
|
surfaceContainerHigh = Color.White,
|
|
surfaceContainerHighest = Color.White,
|
|
inverseSurface = Color.Black,
|
|
inverseOnSurface = Color.White,
|
|
inversePrimary = Color.Black,
|
|
outline = Color.Gray,
|
|
outlineVariant = Color.Gray,
|
|
scrim = Color.Black,
|
|
status =
|
|
HermesStatusColors(
|
|
success = Color.Green,
|
|
successContainer = Color.White,
|
|
onSuccess = Color.Black,
|
|
warning = Color.Yellow,
|
|
warningContainer = Color.White,
|
|
onWarning = Color.Black,
|
|
error = Color.Red,
|
|
errorContainer = Color.White,
|
|
onError = Color.Black,
|
|
onErrorContainer = Color.Black,
|
|
info = Color.Blue,
|
|
infoContainer = Color.White,
|
|
onInfo = Color.Black,
|
|
),
|
|
)
|
|
}
|