commit 9e619f94f7db462c008e104cc18b86e289bce276
parent a9e89746e47ec3d028f3bd6afd19f1c7299c0ac1
Author: triesap <tyson@radroots.org>
Date: Wed, 12 Aug 2026 17:06:17 +0000
ui: correct shell control state colors
- Resolve semantic button families independently from interaction and focus.
- Keep destructive states critical and primary states within primary tokens.
- Represent focus through border and ring values without changing the fill.
- Verify the complete matrix and rendered Light and Dark color evidence.
Diffstat:
3 files changed, 183 insertions(+), 48 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt
@@ -27,6 +27,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.SemanticsPropertyKey
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.role
@@ -38,32 +39,75 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import org.harvestcircle.design.ColorToken
import org.harvestcircle.design.FontWeightToken
import org.harvestcircle.design.HarvestCircleDesign
+import org.harvestcircle.design.HarvestCirclePalette
import org.harvestcircle.design.TypographyToken
enum class ShellTextRole { ScreenTitle, SectionTitle, CardTitle, Body, Secondary, Protocol, Button }
enum class ShellButtonKind { Primary, Secondary, Quiet, Destructive }
-enum class ShellControlVisualState { Normal, Hovered, Pressed, Focused, Selected, Disabled }
+sealed interface ShellControlBackground {
+ data class Solid(
+ val color: ColorToken,
+ ) : ShellControlBackground
-fun shellControlVisualState(
+ data object Transparent : ShellControlBackground
+}
+
+data class ShellControlVisuals(
+ val background: ShellControlBackground,
+ val foreground: ColorToken,
+ val border: ColorToken,
+ val focusRing: ColorToken?,
+)
+
+internal val ShellControlBackgroundKey = SemanticsPropertyKey<String>("ShellControlBackground")
+internal val ShellControlForegroundKey = SemanticsPropertyKey<String>("ShellControlForeground")
+internal val ShellControlBorderKey = SemanticsPropertyKey<String>("ShellControlBorder")
+
+fun resolveShellControlVisuals(
+ kind: ShellButtonKind,
enabled: Boolean,
selected: Boolean,
focused: Boolean,
pressed: Boolean,
hovered: Boolean,
-): ShellControlVisualState =
- when {
- !enabled && selected -> ShellControlVisualState.Selected
- !enabled -> ShellControlVisualState.Disabled
- focused -> ShellControlVisualState.Focused
- pressed -> ShellControlVisualState.Pressed
- selected -> ShellControlVisualState.Selected
- hovered -> ShellControlVisualState.Hovered
- else -> ShellControlVisualState.Normal
+ palette: HarvestCirclePalette,
+): ShellControlVisuals {
+ if (!enabled) {
+ return ShellControlVisuals(
+ background = ShellControlBackground.Solid(palette.surfaceSecondary),
+ foreground = palette.textSecondary,
+ border = palette.border,
+ focusRing = null,
+ )
}
+ val background =
+ when {
+ selected -> ShellControlBackground.Solid(if (hovered && !pressed) palette.primaryHover else palette.primary)
+ kind == ShellButtonKind.Primary ->
+ ShellControlBackground.Solid(if (hovered && !pressed) palette.primaryHover else palette.primary)
+ kind == ShellButtonKind.Destructive -> ShellControlBackground.Solid(palette.critical)
+ kind == ShellButtonKind.Secondary -> ShellControlBackground.Solid(palette.surfaceSecondary)
+ pressed || hovered -> ShellControlBackground.Solid(palette.surfaceSecondary)
+ else -> ShellControlBackground.Transparent
+ }
+ val foreground =
+ if (selected || kind == ShellButtonKind.Primary || kind == ShellButtonKind.Destructive) {
+ palette.surface
+ } else {
+ palette.textPrimary
+ }
+ return ShellControlVisuals(
+ background = background,
+ foreground = foreground,
+ border = if (focused) palette.focus else palette.border,
+ focusRing = if (focused) palette.focus else null,
+ )
+}
@Composable
fun ShellSurface(
@@ -132,41 +176,15 @@ fun ShellButton(
val hovered by interactionSource.collectIsHoveredAsState()
val pressed by interactionSource.collectIsPressedAsState()
var focused by remember { mutableStateOf(false) }
- val state = shellControlVisualState(enabled, selected, focused, pressed, hovered)
val palette = LocalHarvestCirclePalette.current
- val background =
- when (state) {
- ShellControlVisualState.Disabled -> palette.surfaceSecondary.toComposeColor()
- ShellControlVisualState.Hovered -> palette.primaryHover.toComposeColor()
- ShellControlVisualState.Pressed -> palette.primary.toComposeColor()
- ShellControlVisualState.Focused -> palette.surface.toComposeColor()
- ShellControlVisualState.Selected -> palette.primary.toComposeColor()
- ShellControlVisualState.Normal ->
- when (kind) {
- ShellButtonKind.Primary -> palette.primary.toComposeColor()
- ShellButtonKind.Destructive -> palette.critical.toComposeColor()
- ShellButtonKind.Secondary -> palette.surfaceSecondary.toComposeColor()
- ShellButtonKind.Quiet -> Color.Transparent
- }
- }
- val foreground =
- if (state == ShellControlVisualState.Disabled) {
- palette.textSecondary.toComposeColor()
- } else if (state in setOf(ShellControlVisualState.Hovered, ShellControlVisualState.Pressed, ShellControlVisualState.Selected) ||
- kind == ShellButtonKind.Primary ||
- kind == ShellButtonKind.Destructive
- ) {
- palette.surface.toComposeColor()
- } else {
- palette.textPrimary.toComposeColor()
- }
+ val visuals = resolveShellControlVisuals(kind, enabled, selected, focused, pressed, hovered, palette)
Box(
modifier
.heightIn(min = HarvestCircleDesign.MINIMUM_TARGET_DP.dp)
- .background(background, RoundedCornerShape(LocalHarvestCircleShapes.current.controlRadiusDp.dp))
+ .background(visuals.background.toComposeColor(), RoundedCornerShape(LocalHarvestCircleShapes.current.controlRadiusDp.dp))
.border(
HarvestCircleDesign.BORDER_DP.dp,
- if (focused) palette.focus.toComposeColor() else palette.border.toComposeColor(),
+ visuals.border.toComposeColor(),
RoundedCornerShape(LocalHarvestCircleShapes.current.controlRadiusDp.dp),
).onFocusChanged { focused = it.isFocused }
.hoverable(interactionSource, enabled)
@@ -176,13 +194,28 @@ fun ShellButton(
contentDescription = description
role = Role.Button
this.selected = selected
+ this[ShellControlBackgroundKey] = visuals.background.semanticValue()
+ this[ShellControlForegroundKey] = visuals.foreground.hex
+ this[ShellControlBorderKey] = visuals.border.hex
if (!enabled) disabled()
}.padding(horizontal = HarvestCircleDesign.spacingDp[3].dp, vertical = HarvestCircleDesign.spacingDp[2].dp),
) {
- ShellText(label, textRole = ShellTextRole.Button, color = foreground)
+ ShellText(label, textRole = ShellTextRole.Button, color = visuals.foreground.toComposeColor())
}
}
+private fun ShellControlBackground.toComposeColor(): Color =
+ when (this) {
+ is ShellControlBackground.Solid -> color.toComposeColor()
+ ShellControlBackground.Transparent -> Color.Transparent
+ }
+
+private fun ShellControlBackground.semanticValue(): String =
+ when (this) {
+ is ShellControlBackground.Solid -> color.hex
+ ShellControlBackground.Transparent -> "transparent"
+ }
+
@Composable
fun ShellIconButton(
glyph: String,
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellControlsTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellControlsTest.kt
@@ -1,16 +1,66 @@
package org.harvestcircle.ui.shell
+import org.harvestcircle.design.HarvestCircleDesign
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+import kotlin.test.assertNull
class ShellControlsTest {
@Test
- fun visualStateUsesAccessibilityAndInteractionPriority() {
- assertEquals(ShellControlVisualState.Selected, shellControlVisualState(false, true, true, true, true))
- assertEquals(ShellControlVisualState.Focused, shellControlVisualState(true, true, true, true, true))
- assertEquals(ShellControlVisualState.Pressed, shellControlVisualState(true, true, false, true, true))
- assertEquals(ShellControlVisualState.Selected, shellControlVisualState(true, true, false, false, true))
- assertEquals(ShellControlVisualState.Hovered, shellControlVisualState(true, false, false, false, true))
- assertEquals(ShellControlVisualState.Normal, shellControlVisualState(true, false, false, false, false))
+ fun focusChangesOnlyBorderAndRingAcrossEveryEnabledControlFamily() {
+ listOf(HarvestCircleDesign.light, HarvestCircleDesign.dark).forEach { palette ->
+ ShellButtonKind.entries.forEach { kind ->
+ listOf(false, true).forEach { selected ->
+ listOf(false, true).forEach { hovered ->
+ listOf(false, true).forEach { pressed ->
+ val unfocused = resolveShellControlVisuals(kind, true, selected, false, pressed, hovered, palette)
+ val focused = resolveShellControlVisuals(kind, true, selected, true, pressed, hovered, palette)
+ assertEquals(unfocused.background, focused.background)
+ assertEquals(unfocused.foreground, focused.foreground)
+ assertEquals(palette.border, unfocused.border)
+ assertNull(unfocused.focusRing)
+ assertEquals(palette.focus, focused.border)
+ assertEquals(palette.focus, focused.focusRing)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ @Test
+ fun interactionPreservesPrimaryAndDestructiveSemanticFamilies() {
+ listOf(HarvestCircleDesign.light, HarvestCircleDesign.dark).forEach { palette ->
+ listOf(false, true).forEach { hovered ->
+ listOf(false, true).forEach { pressed ->
+ val primary = resolveShellControlVisuals(ShellButtonKind.Primary, true, false, false, pressed, hovered, palette)
+ val destructive =
+ resolveShellControlVisuals(ShellButtonKind.Destructive, true, false, false, pressed, hovered, palette)
+ assertEquals(palette.surface, primary.foreground)
+ assertEquals(palette.surface, destructive.foreground)
+ assertEquals(ShellControlBackground.Solid(palette.critical), destructive.background)
+ assertNotEquals(ShellControlBackground.Solid(palette.primary), destructive.background)
+ assertNotEquals(ShellControlBackground.Solid(palette.primaryHover), destructive.background)
+ }
+ }
+ val hovered = resolveShellControlVisuals(ShellButtonKind.Primary, true, false, false, false, true, palette)
+ val pressed = resolveShellControlVisuals(ShellButtonKind.Primary, true, false, false, true, true, palette)
+ assertEquals(ShellControlBackground.Solid(palette.primaryHover), hovered.background)
+ assertEquals(ShellControlBackground.Solid(palette.primary), pressed.background)
+ }
+ }
+
+ @Test
+ fun disabledControlsUseTheDisabledFamilyRegardlessOfOtherFlags() {
+ listOf(HarvestCircleDesign.light, HarvestCircleDesign.dark).forEach { palette ->
+ ShellButtonKind.entries.forEach { kind ->
+ val visuals = resolveShellControlVisuals(kind, false, true, true, true, true, palette)
+ assertEquals(ShellControlBackground.Solid(palette.surfaceSecondary), visuals.background)
+ assertEquals(palette.textSecondary, visuals.foreground)
+ assertEquals(palette.border, visuals.border)
+ assertNull(visuals.focusRing)
+ }
+ }
}
}
diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt
@@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertIsSelected
@@ -11,6 +13,9 @@ import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.v2.runComposeUiTest
import androidx.compose.ui.unit.dp
+import org.harvestcircle.design.AppearanceState
+import org.harvestcircle.design.HarvestCircleDesign
+import org.harvestcircle.design.ThemePreference
import kotlin.test.Test
@OptIn(ExperimentalTestApi::class)
@@ -38,4 +43,51 @@ class ShellControlsUiTest {
onNodeWithTag("control-field").assertTextContains("npub1…")
onNodeWithTag("control-icon").assertHeightIsAtLeast(44.dp)
}
+
+ @Test
+ fun renderedSemanticColorsMatchThePureResolverInLightAndDark() {
+ listOf(false, true).forEach { systemDark ->
+ val palette = if (systemDark) HarvestCircleDesign.dark else HarvestCircleDesign.light
+ runComposeUiTest {
+ setContent {
+ HarvestCircleTheme(AppearanceState(theme = ThemePreference.System), systemDark = systemDark) {
+ Column {
+ ShellButton(
+ "Primary",
+ "Primary action",
+ {},
+ Modifier.testTag("control-primary"),
+ kind = ShellButtonKind.Primary,
+ )
+ ShellButton(
+ "Destructive",
+ "Destructive action",
+ {},
+ Modifier.testTag("control-destructive"),
+ kind = ShellButtonKind.Destructive,
+ )
+ }
+ }
+ }
+
+ val primary = resolveShellControlVisuals(ShellButtonKind.Primary, true, false, false, false, false, palette)
+ val destructive =
+ resolveShellControlVisuals(ShellButtonKind.Destructive, true, false, false, false, false, palette)
+ onNodeWithTag("control-primary")
+ .assert(SemanticsMatcher.expectValue(ShellControlBackgroundKey, primary.background.hexValue()))
+ .assert(SemanticsMatcher.expectValue(ShellControlForegroundKey, primary.foreground.hex))
+ .assert(SemanticsMatcher.expectValue(ShellControlBorderKey, primary.border.hex))
+ onNodeWithTag("control-destructive")
+ .assert(SemanticsMatcher.expectValue(ShellControlBackgroundKey, destructive.background.hexValue()))
+ .assert(SemanticsMatcher.expectValue(ShellControlForegroundKey, destructive.foreground.hex))
+ .assert(SemanticsMatcher.expectValue(ShellControlBorderKey, destructive.border.hex))
+ }
+ }
+ }
}
+
+private fun ShellControlBackground.hexValue(): String =
+ when (this) {
+ is ShellControlBackground.Solid -> color.hex
+ ShellControlBackground.Transparent -> "transparent"
+ }