app

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

commit 15bdb7b5469f37ab46ff158d695d673467ab2e16
parent 61d4b5f8cee9595c9d4e96122c9c1b76f7980e2a
Author: triesap <tyson@radroots.org>
Date:   Tue, 11 Aug 2026 16:19:34 +0000

ui: establish the HarvestCircle design system

- centralize exact light and dark color tokens
- define typography, spacing, shape, and target scales
- model session-local theme, text, and motion choices
- prove the locked Nordic high-trust token values

Diffstat:
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/design/HarvestCircleDesign.kt | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/commonTest/kotlin/org/harvestcircle/design/HarvestCircleDesignTest.kt | 41+++++++++++++++++++++++++++++++++++++++++
2 files changed, 194 insertions(+), 0 deletions(-)

diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/design/HarvestCircleDesign.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/design/HarvestCircleDesign.kt @@ -0,0 +1,153 @@ +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/commonTest/kotlin/org/harvestcircle/design/HarvestCircleDesignTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/design/HarvestCircleDesignTest.kt @@ -0,0 +1,41 @@ +package org.harvestcircle.design + +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) + } +}