app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt | 115+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellControlsTest.kt | 64+++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
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" + }