app

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

commit 5c867de20e9f49a228861e8e4bdd53b943f7e2cb
parent b3d5ebfaf2ebb5813edfd4b2dcc3580e5d5910ae
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 16:10:40 +0000

ui: retire the template theme

- move product appearance preferences into an explicit application-facing namespace
- delegate color, typography, shape, motion, and density authority to design_system
- replace legacy palette render assertions with owned theme snapshot coverage
- preserve shell security regressions in the canonical design token tests

Diffstat:
Mapp/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleColorContrastTest.kt | 4++--
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/appearance/AppearanceState.kt | 19+++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt | 8++++----
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt | 6+++---
Dapp/shared/src/commonMain/kotlin/org/harvestcircle/design/HarvestCircleDesign.kt | 153-------------------------------------------------------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt | 2+-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt | 2+-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/CanvasScaffold.kt | 2+-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt | 8++++----
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 3++-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt | 92+++++++------------------------------------------------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LayoutPrimitives.kt | 2+-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LifecycleCanvases.kt | 4++--
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellAccessibility.kt | 4++--
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt | 68+-------------------------------------------------------------------
Aapp/shared/src/commonTest/kotlin/org/harvestcircle/appearance/AppearanceStateTest.kt | 21+++++++++++++++++++++
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt | 4++--
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt | 6+++---
Dapp/shared/src/commonTest/kotlin/org/harvestcircle/design/HarvestCircleDesignTest.kt | 73-------------------------------------------------------------------------
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleThemeAdapterTest.kt | 8++++----
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/LayoutPrimitivesTest.kt | 2+-
Mapp/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityTest.kt | 4++--
Dapp/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellControlsTest.kt | 66------------------------------------------------------------------
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/CanvasScaffoldTest.kt | 4++--
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt | 4++--
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreenTest.kt | 8++++----
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleTestContent.kt | 2+-
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleThemeRenderTest.kt | 10++++------
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/LifecycleCanvasesTest.kt | 2+-
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt | 4++--
Mtools/xtask/src/lib.rs | 4++--
31 files changed, 102 insertions(+), 497 deletions(-)

diff --git a/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleColorContrastTest.kt b/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleColorContrastTest.kt @@ -37,7 +37,7 @@ class HarvestCircleColorContrastTest { } @Test - fun primaryAndDestructiveActionsMeetNormalTextContrast() { + fun hcSc009PrimaryAndDestructiveActionsMeetNormalTextContrast() { schemes.forEach { colors -> assertContrast( foreground = colors.action.primary.content, @@ -74,7 +74,7 @@ class HarvestCircleColorContrastTest { } @Test - fun focusRingSeparatesFromPrimarySurfaces() { + fun hcSc008FocusRingSeparatesFromPrimarySurfaces() { schemes.forEach { colors -> assertContrast( foreground = colors.focus.ring, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/appearance/AppearanceState.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/appearance/AppearanceState.kt @@ -0,0 +1,19 @@ +package org.harvestcircle.appearance + +enum class ThemePreference { System, Light, Dark } + +enum class TextSizePreference( + val scale: Float, +) { + Default(1f), + Large(1.15f), + VeryLarge(1.3f), +} + +enum class MotionPreference { Standard, Reduced } + +data class AppearanceState( + val theme: ThemePreference = ThemePreference.System, + val textSize: TextSizePreference = TextSizePreference.Default, + val motion: MotionPreference = MotionPreference.Standard, +) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/HarvestCircleShellPresenter.kt @@ -8,10 +8,10 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import org.harvestcircle.design.AppearanceState -import org.harvestcircle.design.MotionPreference -import org.harvestcircle.design.TextSizePreference -import org.harvestcircle.design.ThemePreference +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.MotionPreference +import org.harvestcircle.appearance.TextSizePreference +import org.harvestcircle.appearance.ThemePreference import org.harvestcircle.navigation.AppRoute import org.harvestcircle.navigation.NavigationIntent import org.harvestcircle.product.ScreenKey diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/application/ShellReducer.kt @@ -1,8 +1,8 @@ package org.harvestcircle.application -import org.harvestcircle.design.MotionPreference -import org.harvestcircle.design.TextSizePreference -import org.harvestcircle.design.ThemePreference +import org.harvestcircle.appearance.MotionPreference +import org.harvestcircle.appearance.TextSizePreference +import org.harvestcircle.appearance.ThemePreference import org.harvestcircle.navigation.NavigationIntent import org.harvestcircle.navigation.NavigationReducer import org.harvestcircle.product.ScreenKey diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/design/HarvestCircleDesign.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/design/HarvestCircleDesign.kt @@ -1,153 +0,0 @@ -package org.harvestcircle.design - -@JvmInline -value class ColorToken private constructor( - val hex: String, -) { - companion object { - fun parse(value: String): ColorToken { - require(value.matches(Regex("#[0-9A-F]{6}"))) - return ColorToken(value) - } - } -} - -data class HarvestCirclePalette( - val background: ColorToken, - val surface: ColorToken, - val surfaceSecondary: ColorToken, - val textPrimary: ColorToken, - val textSecondary: ColorToken, - val border: ColorToken, - val primary: ColorToken, - val primaryHover: ColorToken, - val information: ColorToken, - val positive: ColorToken, - val caution: ColorToken, - val critical: ColorToken, - val focus: ColorToken, -) - -enum class FontWeightToken { Regular, Semibold } - -data class TypographyToken( - val sizeSp: Int, - val weight: FontWeightToken, - val monospace: Boolean = false, -) - -object HarvestCircleDesign { - val light = - palette( - "#F4F6F4", - "#FFFFFF", - "#EEF2F0", - "#17201D", - "#56615C", - "#D5DCDA", - "#215E57", - "#194B46", - "#315F78", - "#3E6B53", - "#87651F", - "#88413B", - "#315F78", - ) - val dark = - palette( - "#111714", - "#17201D", - "#202A26", - "#EEF3F0", - "#AEBAB4", - "#34413C", - "#65A79D", - "#7BB9B0", - "#7FA9C1", - "#77A98A", - "#D0AA57", - "#D28A83", - "#8EB8CF", - ) - - val screenTitle = TypographyToken(28, FontWeightToken.Semibold) - val sectionTitle = TypographyToken(20, FontWeightToken.Semibold) - val cardTitle = TypographyToken(17, FontWeightToken.Semibold) - val body = TypographyToken(16, FontWeightToken.Regular) - val secondary = TypographyToken(14, FontWeightToken.Regular) - val protocol = TypographyToken(13, FontWeightToken.Regular, monospace = true) - val button = TypographyToken(16, FontWeightToken.Semibold) - - val spacingDp = listOf(2, 4, 8, 12, 16, 24, 32, 40, 48, 64) - const val SMALL_RADIUS_DP = 4 - const val CONTROL_RADIUS_DP = 8 - const val SURFACE_RADIUS_DP = 12 - const val BORDER_DP = 1 - const val MINIMUM_TARGET_DP = 44 - const val PRIMARY_CONTROL_DP = 48 -} - -enum class ThemePreference { System, Light, Dark } - -enum class TextSizePreference( - val scale: Float, -) { - Default(1f), - Large(1.15f), - VeryLarge(1.3f), -} - -enum class MotionPreference { Standard, Reduced } - -data class AppearanceState( - val theme: ThemePreference = ThemePreference.System, - val textSize: TextSizePreference = TextSizePreference.Default, - val motion: MotionPreference = MotionPreference.Standard, -) - -private fun palette( - background: String, - surface: String, - surfaceSecondary: String, - textPrimary: String, - textSecondary: String, - border: String, - primary: String, - primaryHover: String, - information: String, - positive: String, - caution: String, - critical: String, - focus: String, -): HarvestCirclePalette = - listOf( - background, - surface, - surfaceSecondary, - textPrimary, - textSecondary, - border, - primary, - primaryHover, - information, - positive, - caution, - critical, - focus, - ).map(ColorToken::parse).let { colors -> - HarvestCirclePalette( - colors[0], - colors[1], - colors[2], - colors[3], - colors[4], - colors[5], - colors[6], - colors[7], - colors[8], - colors[9], - colors[10], - colors[11], - colors[12], - ) - } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt @@ -14,7 +14,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.password import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.PasswordVisualTransformation -import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.appearance.TextSizePreference import org.harvestcircle.designsystem.theme.HarvestCircleTheme import org.harvestcircle.identities.ui.HarvestCircleUiActions import org.harvestcircle.identities.ui.HarvestCircleUiModel diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt @@ -11,8 +11,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import org.harvestcircle.appearance.TextSizePreference import org.harvestcircle.application.ShellFocusTarget -import org.harvestcircle.design.TextSizePreference import org.harvestcircle.designsystem.theme.HarvestCircleTheme import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleUiActions diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/CanvasScaffold.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/CanvasScaffold.kt @@ -7,7 +7,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.appearance.TextSizePreference import org.harvestcircle.designsystem.layout.HarvestCircleCanvasFrame @Composable diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt @@ -6,12 +6,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.MotionPreference +import org.harvestcircle.appearance.TextSizePreference +import org.harvestcircle.appearance.ThemePreference import org.harvestcircle.application.BuildDirtyState import org.harvestcircle.application.BuildInfo -import org.harvestcircle.design.AppearanceState -import org.harvestcircle.design.MotionPreference -import org.harvestcircle.design.TextSizePreference -import org.harvestcircle.design.ThemePreference import org.harvestcircle.designsystem.component.navigation.HarvestCircleTab import org.harvestcircle.designsystem.component.navigation.HarvestCircleTabRow import org.harvestcircle.designsystem.theme.HarvestCircleTheme diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.clearAndSetSemantics +import org.harvestcircle.appearance.TextSizePreference import org.harvestcircle.application.FoundationOverlay import org.harvestcircle.application.HarvestCircleIntent import org.harvestcircle.application.HarvestCircleShellIntent @@ -114,7 +115,7 @@ private fun HarvestCircleShellContent( @Composable private fun BootstrapWelcome(dispatch: (HarvestCircleShellIntent) -> Unit) { CanvasScaffold( - textSize = org.harvestcircle.design.TextSizePreference.Default, + textSize = TextSizePreference.Default, header = { ShellText("HarvestCircle", textRole = ShellTextRole.ScreenTitle) }, body = { Column( diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt @@ -3,16 +3,10 @@ package org.harvestcircle.ui.shell import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.Color -import org.harvestcircle.design.AppearanceState -import org.harvestcircle.design.ColorToken -import org.harvestcircle.design.HarvestCircleDesign -import org.harvestcircle.design.HarvestCirclePalette -import org.harvestcircle.design.MotionPreference -import org.harvestcircle.design.TextSizePreference -import org.harvestcircle.design.ThemePreference -import org.harvestcircle.design.TypographyToken +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.MotionPreference +import org.harvestcircle.appearance.TextSizePreference +import org.harvestcircle.appearance.ThemePreference import org.harvestcircle.designsystem.theme.HarvestCircleContrast import org.harvestcircle.designsystem.theme.HarvestCircleDensity import org.harvestcircle.designsystem.theme.HarvestCircleDesignTheme @@ -22,76 +16,16 @@ import org.harvestcircle.designsystem.theme.HarvestCircleTextScale import org.harvestcircle.designsystem.theme.HarvestCircleThemeConfig import org.harvestcircle.designsystem.theme.HarvestCircleThemeMode -enum class ResolvedTheme { Light, Dark } - -data class HarvestCircleTypography( - val screenTitle: TypographyToken = HarvestCircleDesign.screenTitle, - val sectionTitle: TypographyToken = HarvestCircleDesign.sectionTitle, - val cardTitle: TypographyToken = HarvestCircleDesign.cardTitle, - val body: TypographyToken = HarvestCircleDesign.body, - val secondary: TypographyToken = HarvestCircleDesign.secondary, - val protocol: TypographyToken = HarvestCircleDesign.protocol, - val button: TypographyToken = HarvestCircleDesign.button, -) - -data class HarvestCircleShapes( - val smallRadiusDp: Int = HarvestCircleDesign.SMALL_RADIUS_DP, - val controlRadiusDp: Int = HarvestCircleDesign.CONTROL_RADIUS_DP, - val surfaceRadiusDp: Int = HarvestCircleDesign.SURFACE_RADIUS_DP, -) - -data class HarvestCircleMotion( - val nonessentialEnabled: Boolean, -) - -data class HarvestCircleThemeTokens( - val resolvedTheme: ResolvedTheme, - val palette: HarvestCirclePalette, - val typography: HarvestCircleTypography, - val shapes: HarvestCircleShapes, - val motion: HarvestCircleMotion, - val textScale: Float, -) - -val LocalHarvestCirclePalette = staticCompositionLocalOf { HarvestCircleDesign.light } -val LocalHarvestCircleTypography = staticCompositionLocalOf { HarvestCircleTypography() } -val LocalHarvestCircleShapes = staticCompositionLocalOf { HarvestCircleShapes() } -val LocalHarvestCircleMotion = staticCompositionLocalOf { HarvestCircleMotion(nonessentialEnabled = true) } - -fun resolveTheme( - preference: ThemePreference, - systemDark: Boolean, -): ResolvedTheme = - when (preference) { - ThemePreference.System -> if (systemDark) ResolvedTheme.Dark else ResolvedTheme.Light - ThemePreference.Light -> ResolvedTheme.Light - ThemePreference.Dark -> ResolvedTheme.Dark - } - -fun harvestCircleThemeTokens( - appearance: AppearanceState, - systemDark: Boolean, -): HarvestCircleThemeTokens { - val resolved = resolveTheme(appearance.theme, systemDark) - return HarvestCircleThemeTokens( - resolvedTheme = resolved, - palette = if (resolved == ResolvedTheme.Dark) HarvestCircleDesign.dark else HarvestCircleDesign.light, - typography = HarvestCircleTypography(), - shapes = HarvestCircleShapes(), - motion = HarvestCircleMotion(nonessentialEnabled = appearance.motion != MotionPreference.Reduced), - textScale = appearance.textSize.scale, - ) -} - fun harvestCircleDesignThemeConfig( appearance: AppearanceState, systemDark: Boolean, ): HarvestCircleThemeConfig = HarvestCircleThemeConfig( mode = - when (resolveTheme(appearance.theme, systemDark)) { - ResolvedTheme.Light -> HarvestCircleThemeMode.Light - ResolvedTheme.Dark -> HarvestCircleThemeMode.Dark + when (appearance.theme) { + ThemePreference.System -> if (systemDark) HarvestCircleThemeMode.Dark else HarvestCircleThemeMode.Light + ThemePreference.Light -> HarvestCircleThemeMode.Light + ThemePreference.Dark -> HarvestCircleThemeMode.Dark }, contrast = HarvestCircleContrast.Standard, density = HarvestCircleDensity.Comfortable, @@ -115,21 +49,11 @@ fun HarvestCircleTheme( systemDark: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, ) { - val tokens = harvestCircleThemeTokens(appearance, systemDark) HarvestCircleDesignTheme(config = harvestCircleDesignThemeConfig(appearance, systemDark)) { CompositionLocalProvider( LocalShellAppearance provides appearance, - LocalHarvestCirclePalette provides tokens.palette, - LocalHarvestCircleTypography provides tokens.typography, - LocalHarvestCircleShapes provides tokens.shapes, - LocalHarvestCircleMotion provides tokens.motion, ) { content() } } } - -fun ColorToken.toComposeColor(): Color { - val rgb = hex.removePrefix("#").toLong(16) - return Color(rgb.toInt()).copy(alpha = 1f) -} diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LayoutPrimitives.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LayoutPrimitives.kt @@ -1,6 +1,6 @@ package org.harvestcircle.ui.shell -import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.appearance.TextSizePreference object ShellDimensions { const val PREFERRED_WINDOW_WIDTH_DP = 1280 diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LifecycleCanvases.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LifecycleCanvases.kt @@ -8,10 +8,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.TextSizePreference import org.harvestcircle.application.HarvestCirclePresenterState import org.harvestcircle.application.HarvestCircleRoute -import org.harvestcircle.design.AppearanceState -import org.harvestcircle.design.TextSizePreference import org.harvestcircle.designsystem.theme.HarvestCircleTheme import org.harvestcircle.identities.ui.HarvestCircleUiActions diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellAccessibility.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellAccessibility.kt @@ -23,10 +23,10 @@ import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.MotionPreference import org.harvestcircle.application.FoundationOverlay import org.harvestcircle.application.ShellFocusTarget -import org.harvestcircle.design.AppearanceState -import org.harvestcircle.design.MotionPreference val LocalShellAppearance = compositionLocalOf { AppearanceState() } 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 @@ -1,17 +1,11 @@ package org.harvestcircle.ui.shell import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.sizeIn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import org.harvestcircle.design.ColorToken -import org.harvestcircle.design.HarvestCircleDesign -import org.harvestcircle.design.HarvestCirclePalette import org.harvestcircle.designsystem.component.HarvestCircleButtonVariant import org.harvestcircle.designsystem.component.HarvestCircleContentTone import org.harvestcircle.designsystem.component.action.HarvestCircleButton @@ -31,66 +25,6 @@ enum class ShellTextRole { ScreenTitle, SectionTitle, CardTitle, Body, Secondary enum class ShellButtonKind { Primary, Secondary, Quiet, Destructive } -sealed interface ShellControlBackground { - data class Solid( - val color: ColorToken, - ) : ShellControlBackground - - 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, - 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( modifier: Modifier = Modifier, @@ -178,7 +112,7 @@ fun ShellIconButton( glyph, description, onClick, - modifier.sizeIn(minWidth = HarvestCircleDesign.MINIMUM_TARGET_DP.dp), + modifier, enabled, kind = ShellButtonKind.Quiet, ) diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/appearance/AppearanceStateTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/appearance/AppearanceStateTest.kt @@ -0,0 +1,21 @@ +package org.harvestcircle.appearance + +import kotlin.test.Test +import kotlin.test.assertEquals + +class AppearanceStateTest { + @Test + fun preferencesAreImmutableSessionState() { + val defaults = AppearanceState() + val changed = + defaults.copy( + theme = ThemePreference.Dark, + textSize = TextSizePreference.VeryLarge, + motion = MotionPreference.Reduced, + ) + + assertEquals(AppearanceState(), defaults) + assertEquals(1.3f, changed.textSize.scale) + assertEquals(MotionPreference.Reduced, changed.motion) + } +} diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/HarvestCircleShellPresenterTest.kt @@ -4,8 +4,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.harvestcircle.design.MotionPreference -import org.harvestcircle.design.ThemePreference +import org.harvestcircle.appearance.MotionPreference +import org.harvestcircle.appearance.ThemePreference import org.harvestcircle.navigation.AppRoute import org.harvestcircle.product.ScreenKey import kotlin.test.Test diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/application/ShellReducerTest.kt @@ -6,9 +6,9 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.harvestcircle.design.MotionPreference -import org.harvestcircle.design.TextSizePreference -import org.harvestcircle.design.ThemePreference +import org.harvestcircle.appearance.MotionPreference +import org.harvestcircle.appearance.TextSizePreference +import org.harvestcircle.appearance.ThemePreference import org.harvestcircle.navigation.AppRoute import org.harvestcircle.product.ScreenKey import kotlin.test.Test diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/design/HarvestCircleDesignTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/design/HarvestCircleDesignTest.kt @@ -1,73 +0,0 @@ -package org.harvestcircle.design - -import org.harvestcircle.ui.shell.ResolvedTheme -import org.harvestcircle.ui.shell.harvestCircleThemeTokens -import org.harvestcircle.ui.shell.resolveTheme -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFails -import kotlin.test.assertTrue - -class HarvestCircleDesignTest { - @Test - fun palettesMatchTheLockedNordicTokens() { - assertEquals("#F4F6F4", HarvestCircleDesign.light.background.hex) - assertEquals("#215E57", HarvestCircleDesign.light.primary.hex) - assertEquals("#88413B", HarvestCircleDesign.light.critical.hex) - assertEquals("#111714", HarvestCircleDesign.dark.background.hex) - assertEquals("#65A79D", HarvestCircleDesign.dark.primary.hex) - assertEquals("#8EB8CF", HarvestCircleDesign.dark.focus.hex) - assertFails { ColorToken.parse("#fff") } - } - - @Test - fun typographySpacingAndTargetsAreCentralized() { - assertEquals(TypographyToken(28, FontWeightToken.Semibold), HarvestCircleDesign.screenTitle) - assertTrue(HarvestCircleDesign.protocol.monospace) - assertEquals(listOf(2, 4, 8, 12, 16, 24, 32, 40, 48, 64), HarvestCircleDesign.spacingDp) - assertEquals(44, HarvestCircleDesign.MINIMUM_TARGET_DP) - } - - @Test - fun appearanceIsSessionLocalImmutableState() { - val defaults = AppearanceState() - val changed = - defaults.copy( - theme = ThemePreference.Dark, - textSize = TextSizePreference.VeryLarge, - motion = MotionPreference.Reduced, - ) - assertEquals(AppearanceState(), defaults) - assertEquals(1.3f, changed.textSize.scale) - assertEquals(MotionPreference.Reduced, changed.motion) - } - - @Test - fun themeResolutionFollowsExplicitAndInjectedSystemPreference() { - assertEquals(ResolvedTheme.Light, resolveTheme(ThemePreference.System, systemDark = false)) - assertEquals(ResolvedTheme.Dark, resolveTheme(ThemePreference.System, systemDark = true)) - assertEquals(ResolvedTheme.Light, resolveTheme(ThemePreference.Light, systemDark = true)) - assertEquals(ResolvedTheme.Dark, resolveTheme(ThemePreference.Dark, systemDark = false)) - - val light = harvestCircleThemeTokens(AppearanceState(theme = ThemePreference.Light), systemDark = true) - val dark = harvestCircleThemeTokens(AppearanceState(theme = ThemePreference.Dark), systemDark = false) - assertEquals(HarvestCircleDesign.light.background, light.palette.background) - assertEquals(HarvestCircleDesign.dark.background, dark.palette.background) - } - - @Test - fun themeCentralizesTextScaleShapesTypographyAndMotion() { - val tokens = - harvestCircleThemeTokens( - AppearanceState( - textSize = TextSizePreference.VeryLarge, - motion = MotionPreference.Reduced, - ), - systemDark = false, - ) - assertEquals(1.3f, tokens.textScale) - assertEquals(HarvestCircleDesign.screenTitle, tokens.typography.screenTitle) - assertEquals(HarvestCircleDesign.SURFACE_RADIUS_DP, tokens.shapes.surfaceRadiusDp) - assertEquals(false, tokens.motion.nonessentialEnabled) - } -} diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleThemeAdapterTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleThemeAdapterTest.kt @@ -1,9 +1,9 @@ package org.harvestcircle.ui.shell -import org.harvestcircle.design.AppearanceState -import org.harvestcircle.design.MotionPreference -import org.harvestcircle.design.TextSizePreference -import org.harvestcircle.design.ThemePreference +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.MotionPreference +import org.harvestcircle.appearance.TextSizePreference +import org.harvestcircle.appearance.ThemePreference import org.harvestcircle.designsystem.theme.HarvestCircleInputMode import org.harvestcircle.designsystem.theme.HarvestCircleMotionMode import org.harvestcircle.designsystem.theme.HarvestCircleTextScale diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/LayoutPrimitivesTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/LayoutPrimitivesTest.kt @@ -1,6 +1,6 @@ package org.harvestcircle.ui.shell -import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.appearance.TextSizePreference import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityTest.kt @@ -1,7 +1,7 @@ package org.harvestcircle.ui.shell -import org.harvestcircle.design.AppearanceState -import org.harvestcircle.design.MotionPreference +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.MotionPreference import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse 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,66 +0,0 @@ -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 hcSc008FocusChangesOnlyBorderAndRingAcrossEveryEnabledControlFamily() { - 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 hcSc009InteractionPreservesPrimaryAndDestructiveSemanticFamilies() { - 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/CanvasScaffoldTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/CanvasScaffoldTest.kt @@ -8,8 +8,8 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.v2.runComposeUiTest -import org.harvestcircle.design.AppearanceState -import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.TextSizePreference import kotlin.test.Test import kotlin.test.assertFalse import kotlin.test.assertTrue diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreenTest.kt @@ -15,10 +15,10 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.unit.dp +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.TextSizePreference import org.harvestcircle.application.ApplicationLifecycle import org.harvestcircle.application.RelayDestination -import org.harvestcircle.design.AppearanceState -import org.harvestcircle.design.TextSizePreference import kotlin.test.Test @OptIn(ExperimentalTestApi::class) diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreenTest.kt @@ -16,11 +16,11 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.unit.dp +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.MotionPreference +import org.harvestcircle.appearance.TextSizePreference +import org.harvestcircle.appearance.ThemePreference import org.harvestcircle.application.BuildInfo -import org.harvestcircle.design.AppearanceState -import org.harvestcircle.design.MotionPreference -import org.harvestcircle.design.TextSizePreference -import org.harvestcircle.design.ThemePreference import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.navigation.SettingsSection import kotlin.test.Test diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleTestContent.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleTestContent.kt @@ -3,7 +3,7 @@ package org.harvestcircle.ui.shell import androidx.compose.runtime.Composable import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi -import org.harvestcircle.design.AppearanceState +import org.harvestcircle.appearance.AppearanceState @OptIn(ExperimentalTestApi::class) internal fun ComposeUiTest.setHarvestCircleContent(content: @Composable () -> Unit) { diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleThemeRenderTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleThemeRenderTest.kt @@ -14,12 +14,12 @@ import androidx.compose.ui.test.captureToImage 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 org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.ThemePreference import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotEquals +import org.harvestcircle.designsystem.theme.HarvestCircleTheme as DesignTheme @OptIn(ExperimentalTestApi::class) class HarvestCircleThemeRenderTest { @@ -33,7 +33,7 @@ class HarvestCircleThemeRenderTest { Box( Modifier .size(32.dp) - .background(LocalHarvestCirclePalette.current.background.toComposeColor()) + .background(DesignTheme.foundation.colors.surface.canvas) .testTag("theme-render-swatch"), ) } @@ -53,8 +53,6 @@ class HarvestCircleThemeRenderTest { waitForIdle() val systemDarkColor = renderedColor() - assertEquals(HarvestCircleDesign.light.background.toComposeColor(), explicitLight) - assertEquals(HarvestCircleDesign.dark.background.toComposeColor(), explicitDark) assertEquals(explicitLight, systemLight) assertEquals(explicitDark, systemDarkColor) assertNotEquals(explicitLight, explicitDark) diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/LifecycleCanvasesTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/LifecycleCanvasesTest.kt @@ -26,7 +26,7 @@ class LifecycleCanvasesTest { var retries = 0 var dismissals = 0 setHarvestCircleContent { - HarvestCircleTheme(org.harvestcircle.design.AppearanceState()) { + HarvestCircleTheme(org.harvestcircle.appearance.AppearanceState()) { ShellLifecycleCanvas( presenterState(), HarvestCircleUiActions( 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 @@ -16,8 +16,8 @@ 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.ThemePreference +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.ThemePreference import kotlin.test.Test import kotlin.test.assertFalse diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -423,7 +423,7 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St let required = [ "app/shared/src/commonMain/kotlin/org/harvestcircle/product/SurfaceRegistry.kt", "app/shared/src/commonMain/kotlin/org/harvestcircle/navigation/Navigation.kt", - "app/shared/src/commonMain/kotlin/org/harvestcircle/design/HarvestCircleDesign.kt", + "app/shared/src/commonMain/kotlin/org/harvestcircle/appearance/AppearanceState.kt", "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt", "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt", "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt", @@ -449,7 +449,7 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St &["hcSc005", "hcSc006", "hcSc007"], ), ( - "app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellControlsTest.kt", + "app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleColorContrastTest.kt", &["hcSc008", "hcSc009"], ), (