app

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

commit aceccd90a58a1ee5381eeac1e9ed4cc6415ffb68
parent 281c5081904857399701d8b37132bebc6faade09
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 15:23:42 +0000

design: own HarvestCircle theme foundation

- port Studio colors metrics shapes and bundled typography
- replace Material and platformtools bridges with native Compose state
- expose one fail-fast foundation shell and component token snapshot
- prove contrast interaction targets appearance and bounded text scales

Diffstat:
Aapp/design_system/src/commonMain/composeResources/values/strings.xml | 4++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/ExperimentalHarvestCircleUiApi.kt | 17+++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleMetrics.kt | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleShapes.kt | 43+++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleTheme.kt | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleThemeConfig.kt | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleThemeSnapshot.kt | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleTypography.kt | 150+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/color/HarvestCircleColors.kt | 667+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleColorContrastTest.kt | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleSystemAppearanceTest.kt | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleTokenInvariantTest.kt | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/DesktopDesignKmp.kt | 2+-
Mtools/xtask/src/lib.rs | 21+++++++++++++++++++++
14 files changed, 1566 insertions(+), 1 deletion(-)

diff --git a/app/design_system/src/commonMain/composeResources/values/strings.xml b/app/design_system/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,4 @@ +<resources> + <string name="harvestcircle_loading">Loading</string> + <string name="harvestcircle_error">Error</string> +</resources> diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/ExperimentalHarvestCircleUiApi.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/ExperimentalHarvestCircleUiApi.kt @@ -0,0 +1,17 @@ +package org.harvestcircle.designsystem + +/** + * Marks a HarvestCircle UI API whose source or behavior contract may still change after cross-platform + * product validation. + */ +@RequiresOptIn( + message = "This HarvestCircle UI API is not yet contract-stable.", + level = RequiresOptIn.Level.WARNING, +) +@Retention(AnnotationRetention.BINARY) +@Target( + AnnotationTarget.CLASS, + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY, +) +public annotation class ExperimentalHarvestCircleUiApi diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleMetrics.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleMetrics.kt @@ -0,0 +1,190 @@ +package org.harvestcircle.designsystem.theme + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** A small, intentional spacing scale for local composition. */ +@Immutable +public class HarvestCircleSpacing internal constructor( + public val none: Dp, + public val xxs: Dp, + public val xs: Dp, + public val sm: Dp, + public val md: Dp, + public val lg: Dp, + public val xl: Dp, + public val xxl: Dp, + public val xxxl: Dp, +) + +/** Semantic spacing for repeated product structure. */ +@Immutable +public class HarvestCircleLayout internal constructor( + public val pageInset: Dp, + public val paneInset: Dp, + public val sectionGap: Dp, + public val contentGap: Dp, + public val fieldGap: Dp, + public val inlineGap: Dp, +) + +/** + * Canonical control and application dimensions. + * + * Visual control sizes follow the compact AppKit metrics used from Big Sur through Sequoia. The + * interactive target is separately expanded for touch hosts through [HarvestCircleInputMode]. + */ +@Immutable +public class HarvestCircleDimensions internal constructor( + public val minimumInteractive: Dp, + public val controlSmall: Dp, + public val controlMedium: Dp, + public val controlLarge: Dp, + public val iconSmall: Dp, + public val iconMedium: Dp, + public val iconLarge: Dp, + public val toolbarHeight: Dp, + public val rowHeight: Dp, + public val focusRingWidth: Dp, + public val focusRingGap: Dp, + public val dividerWidth: Dp, + public val selectionControl: Dp, + public val switchWidth: Dp, + public val switchHeight: Dp, + public val menuItemHeight: Dp, +) + +/** Shadow elevations. Surfaces remain mostly flat, as in canonical AppKit windows. */ +@Immutable +public class HarvestCircleElevations internal constructor( + public val flat: Dp, + public val raised: Dp, + public val overlay: Dp, + public val dialog: Dp, +) + +/** Animation durations in milliseconds. */ +@Immutable +public class HarvestCircleMotion internal constructor( + public val immediateMillis: Int, + public val quickMillis: Int, + public val standardMillis: Int, + public val deliberateMillis: Int, +) + +internal val HarvestCircleDefaultSpacing: HarvestCircleSpacing = + HarvestCircleSpacing( + none = 0.dp, + xxs = 2.dp, + xs = 4.dp, + sm = 6.dp, + md = 8.dp, + lg = 12.dp, + xl = 16.dp, + xxl = 24.dp, + xxxl = 32.dp, + ) + +internal fun harvestCircleLayout(density: HarvestCircleDensity): HarvestCircleLayout = + when (density) { + HarvestCircleDensity.Compact -> + HarvestCircleLayout( + pageInset = 16.dp, + paneInset = 12.dp, + sectionGap = 18.dp, + contentGap = 10.dp, + fieldGap = 8.dp, + inlineGap = 6.dp, + ) + + HarvestCircleDensity.Comfortable -> + HarvestCircleLayout( + pageInset = 20.dp, + paneInset = 16.dp, + sectionGap = 24.dp, + contentGap = 12.dp, + fieldGap = 10.dp, + inlineGap = 8.dp, + ) + } + +internal fun harvestCircleDimensions( + density: HarvestCircleDensity, + inputMode: HarvestCircleInputMode, +): HarvestCircleDimensions { + val minimumInteractive = + when (inputMode) { + HarvestCircleInputMode.Pointer -> if (density == HarvestCircleDensity.Compact) 28.dp else 32.dp + HarvestCircleInputMode.Touch -> 48.dp + } + + return when (density) { + HarvestCircleDensity.Compact -> + HarvestCircleDimensions( + minimumInteractive = minimumInteractive, + controlSmall = 20.dp, + controlMedium = 24.dp, + controlLarge = 28.dp, + iconSmall = 12.dp, + iconMedium = 15.dp, + iconLarge = 18.dp, + toolbarHeight = 38.dp, + rowHeight = 26.dp, + focusRingWidth = 2.dp, + focusRingGap = 2.dp, + dividerWidth = 1.dp, + selectionControl = 13.dp, + switchWidth = 28.dp, + switchHeight = 16.dp, + menuItemHeight = 22.dp, + ) + + HarvestCircleDensity.Comfortable -> + HarvestCircleDimensions( + minimumInteractive = minimumInteractive, + controlSmall = 22.dp, + controlMedium = 28.dp, + controlLarge = 32.dp, + iconSmall = 13.dp, + iconMedium = 16.dp, + iconLarge = 20.dp, + toolbarHeight = 44.dp, + rowHeight = 30.dp, + focusRingWidth = 2.dp, + focusRingGap = 2.dp, + dividerWidth = 1.dp, + selectionControl = 14.dp, + switchWidth = 32.dp, + switchHeight = 18.dp, + menuItemHeight = 24.dp, + ) + } +} + +internal val HarvestCircleDefaultElevations: HarvestCircleElevations = + HarvestCircleElevations( + flat = 0.dp, + raised = 1.dp, + overlay = 10.dp, + dialog = 18.dp, + ) + +internal fun harvestCircleMotion(mode: HarvestCircleMotionMode): HarvestCircleMotion = + when (mode) { + HarvestCircleMotionMode.Full -> + HarvestCircleMotion( + immediateMillis = 0, + quickMillis = 70, + standardMillis = 140, + deliberateMillis = 220, + ) + + HarvestCircleMotionMode.Reduced -> + HarvestCircleMotion( + immediateMillis = 0, + quickMillis = 0, + standardMillis = 0, + deliberateMillis = 0, + ) + } diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleShapes.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleShapes.kt @@ -0,0 +1,43 @@ +package org.harvestcircle.designsystem.theme + +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.dp + +/** Canonical pre-Liquid-Glass macOS shape roles. */ +@Immutable +public class HarvestCircleShapes internal constructor( + public val control: CornerBasedShape, + public val card: CornerBasedShape, + public val panel: CornerBasedShape, + public val dialog: CornerBasedShape, + public val pill: CornerBasedShape, + /** Parallel outer contour for an optional focus ring around [control]. */ + public val controlFocusRing: CornerBasedShape, + /** Parallel outer contour for an optional focus ring around [card]. */ + public val cardFocusRing: CornerBasedShape, + /** Parallel outer contour for an optional focus ring around [panel]. */ + public val panelFocusRing: CornerBasedShape, + /** Parallel outer contour for an optional focus ring around [dialog]. */ + public val dialogFocusRing: CornerBasedShape, + /** Parallel outer contour for an optional focus ring around [pill]. */ + public val pillFocusRing: CornerBasedShape, +) + +internal val HarvestCircleDefaultShapes: HarvestCircleShapes = + HarvestCircleShapes( + control = RoundedCornerShape(6.dp), + card = RoundedCornerShape(8.dp), + panel = RoundedCornerShape(10.dp), + dialog = RoundedCornerShape(12.dp), + pill = RoundedCornerShape(percent = 50), + // Focus frames reserve 2.dp for the ring and 2.dp for separation. Adding that 4.dp to the + // Radius keeps the inner and outer curves parallel instead of reusing the inner radius on a + // larger rectangle, which creates visibly mismatched corners. + controlFocusRing = RoundedCornerShape(10.dp), + cardFocusRing = RoundedCornerShape(12.dp), + panelFocusRing = RoundedCornerShape(14.dp), + dialogFocusRing = RoundedCornerShape(16.dp), + pillFocusRing = RoundedCornerShape(percent = 50), + ) diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleTheme.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleTheme.kt @@ -0,0 +1,61 @@ +package org.harvestcircle.designsystem.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.text.selection.LocalTextSelectionColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember + +private val LocalHarvestCircleTheme = + compositionLocalOf<HarvestCircleThemeSnapshot> { + error("HarvestCircleDesignTheme is missing from the composition") + } + +/** Read-only access to the active, immutable HarvestCircle design token graph. */ +public object HarvestCircleTheme { + public val foundation: HarvestCircleFoundationTokens + @Composable + @ReadOnlyComposable + get() = LocalHarvestCircleTheme.current.foundation + + public val shell: HarvestCircleShellTokens + @Composable + @ReadOnlyComposable + get() = LocalHarvestCircleTheme.current.shell + + public val component: HarvestCircleComponentTokens + @Composable + @ReadOnlyComposable + get() = LocalHarvestCircleTheme.current.component +} + +/** Installs exactly one fail-fast token snapshot without a Material compatibility layer. */ +@Composable +public fun HarvestCircleDesignTheme( + config: HarvestCircleThemeConfig = HarvestCircleThemeConfig(), + content: @Composable () -> Unit, +) { + val dark = resolveHarvestCircleDarkTheme(config.mode, isSystemInDarkTheme()) + val typography = rememberHarvestCircleTypography(config.textScale.factor) + val snapshot = + remember(dark, config, typography) { + createHarvestCircleThemeSnapshot(dark, config, typography) + } + CompositionLocalProvider( + LocalHarvestCircleTheme provides snapshot, + LocalTextSelectionColors provides snapshot.textSelectionColors, + content = content, + ) +} + +internal fun resolveHarvestCircleDarkTheme( + mode: HarvestCircleThemeMode, + systemDark: Boolean, +): Boolean = + when (mode) { + HarvestCircleThemeMode.System -> systemDark + HarvestCircleThemeMode.Light -> false + HarvestCircleThemeMode.Dark -> true + } diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleThemeConfig.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleThemeConfig.kt @@ -0,0 +1,59 @@ +package org.harvestcircle.designsystem.theme + +import androidx.compose.runtime.Immutable + +/** Controls whether HarvestCircle follows the system appearance or uses a fixed appearance. */ +public enum class HarvestCircleThemeMode { + System, + Light, + Dark, +} + +/** Controls visual separation without changing the HarvestCircle brand. */ +public enum class HarvestCircleContrast { + Standard, + High, +} + +/** Controls visual density using pre-Liquid-Glass macOS metrics. */ +public enum class HarvestCircleDensity { + Compact, + Comfortable, +} + +/** Controls whether non-essential movement is animated. */ +public enum class HarvestCircleMotionMode { + Full, + Reduced, +} + +/** + * Selects the interaction target policy independently from the visual control metrics. + * + * [Pointer] is the default for the desktop and browser application. Use [Touch] when the same + * design-system module is hosted by an Android or other touch-first application. + */ +public enum class HarvestCircleInputMode { + Pointer, + Touch, +} + +/** Product-supported text scales; arbitrary scaling is intentionally excluded. */ +public enum class HarvestCircleTextScale( + internal val factor: Float, +) { + Standard(1F), + Large(1.15F), + ExtraLarge(1.3F), +} + +/** The complete, orthogonal configuration for [HarvestCircleDesignTheme]. */ +@Immutable +public data class HarvestCircleThemeConfig( + public val mode: HarvestCircleThemeMode = HarvestCircleThemeMode.System, + public val contrast: HarvestCircleContrast = HarvestCircleContrast.Standard, + public val density: HarvestCircleDensity = HarvestCircleDensity.Comfortable, + public val motion: HarvestCircleMotionMode = HarvestCircleMotionMode.Full, + public val inputMode: HarvestCircleInputMode = HarvestCircleInputMode.Pointer, + public val textScale: HarvestCircleTextScale = HarvestCircleTextScale.Standard, +) diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleThemeSnapshot.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleThemeSnapshot.kt @@ -0,0 +1,76 @@ +package org.harvestcircle.designsystem.theme + +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.runtime.Immutable +import org.harvestcircle.designsystem.theme.color.HarvestCircleColorSchemes +import org.harvestcircle.designsystem.theme.color.HarvestCircleColors + +@Immutable +public class HarvestCircleFoundationTokens internal constructor( + public val colors: HarvestCircleColors, + public val typography: HarvestCircleTypography, + public val shapes: HarvestCircleShapes, + public val spacing: HarvestCircleSpacing, +) + +@Immutable +public class HarvestCircleShellTokens internal constructor( + public val layout: HarvestCircleLayout, + public val dimensions: HarvestCircleDimensions, +) + +@Immutable +public class HarvestCircleComponentTokens internal constructor( + public val elevations: HarvestCircleElevations, + public val motion: HarvestCircleMotion, + public val inputMode: HarvestCircleInputMode, +) + +@Immutable +internal class HarvestCircleThemeSnapshot( + val foundation: HarvestCircleFoundationTokens, + val shell: HarvestCircleShellTokens, + val component: HarvestCircleComponentTokens, + val textSelectionColors: TextSelectionColors, +) + +internal fun createHarvestCircleThemeSnapshot( + dark: Boolean, + config: HarvestCircleThemeConfig, + typography: HarvestCircleTypography, +): HarvestCircleThemeSnapshot { + val colors = + when { + dark && config.contrast == HarvestCircleContrast.High -> + HarvestCircleColorSchemes.DarkHighContrast + dark -> HarvestCircleColorSchemes.Dark + config.contrast == HarvestCircleContrast.High -> + HarvestCircleColorSchemes.LightHighContrast + else -> HarvestCircleColorSchemes.Light + } + return HarvestCircleThemeSnapshot( + foundation = + HarvestCircleFoundationTokens( + colors = colors, + typography = typography, + shapes = HarvestCircleDefaultShapes, + spacing = HarvestCircleDefaultSpacing, + ), + shell = + HarvestCircleShellTokens( + layout = harvestCircleLayout(config.density), + dimensions = harvestCircleDimensions(config.density, config.inputMode), + ), + component = + HarvestCircleComponentTokens( + elevations = HarvestCircleDefaultElevations, + motion = harvestCircleMotion(config.motion), + inputMode = config.inputMode, + ), + textSelectionColors = + TextSelectionColors( + handleColor = colors.focus.ring, + backgroundColor = colors.focus.selection, + ), + ) +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleTypography.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleTypography.kt @@ -0,0 +1,150 @@ +package org.harvestcircle.designsystem.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import org.harvestcircle.designsystem.generated.resources.Res +import org.harvestcircle.designsystem.generated.resources.inter_bold +import org.harvestcircle.designsystem.generated.resources.inter_medium +import org.harvestcircle.designsystem.generated.resources.inter_regular +import org.harvestcircle.designsystem.generated.resources.inter_semibold +import org.jetbrains.compose.resources.Font + +/** Semantic typography roles backed by the bundled Inter UI family. */ +@Immutable +public class HarvestCircleTypography internal constructor( + public val display: TextStyle, + public val pageTitle: TextStyle, + public val sectionTitle: TextStyle, + public val subsectionTitle: TextStyle, + public val body: TextStyle, + public val bodyStrong: TextStyle, + public val bodySmall: TextStyle, + public val label: TextStyle, + public val labelSmall: TextStyle, + public val code: TextStyle, +) + +/** Resolves the exact Inter weights used by HarvestCircle without depending on host font installation. */ +@Composable +internal fun rememberHarvestCircleTypography(scale: Float): HarvestCircleTypography { + val sans = + FontFamily( + Font( + resource = Res.font.inter_regular, + weight = FontWeight.Normal, + style = FontStyle.Normal, + ), + Font( + resource = Res.font.inter_medium, + weight = FontWeight.Medium, + style = FontStyle.Normal, + ), + Font( + resource = Res.font.inter_semibold, + weight = FontWeight.SemiBold, + style = FontStyle.Normal, + ), + Font( + resource = Res.font.inter_bold, + weight = FontWeight.Bold, + style = FontStyle.Normal, + ), + ) + + return remember(sans, scale) { + createHarvestCircleTypography(sans = sans, scale = scale) + } +} + +/** + * Pure typography constructor retained for token tests. Production composition supplies Inter via + * [rememberHarvestCircleTypography]; tests may use the platform default family when font resources are absent. + */ +internal fun createHarvestCircleTypography( + sans: FontFamily = FontFamily.Default, + scale: Float = 1F, +): HarvestCircleTypography { + val mono = FontFamily.Monospace + + return HarvestCircleTypography( + display = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.SemiBold, + fontSize = (28 * scale).sp, + lineHeight = (34 * scale).sp, + letterSpacing = (-0.25).sp, + ), + pageTitle = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.SemiBold, + fontSize = (22 * scale).sp, + lineHeight = (27 * scale).sp, + letterSpacing = (-0.15).sp, + ), + sectionTitle = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.SemiBold, + fontSize = (17 * scale).sp, + lineHeight = (22 * scale).sp, + letterSpacing = (-0.05).sp, + ), + subsectionTitle = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.SemiBold, + fontSize = (13 * scale).sp, + lineHeight = (17 * scale).sp, + ), + body = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.Normal, + fontSize = (13 * scale).sp, + lineHeight = (18 * scale).sp, + ), + bodyStrong = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.SemiBold, + fontSize = (13 * scale).sp, + lineHeight = (18 * scale).sp, + ), + bodySmall = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.Normal, + fontSize = (11 * scale).sp, + lineHeight = (15 * scale).sp, + ), + label = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.Medium, + fontSize = (13 * scale).sp, + lineHeight = (17 * scale).sp, + ), + labelSmall = + TextStyle( + fontFamily = sans, + fontWeight = FontWeight.Medium, + fontSize = (11 * scale).sp, + lineHeight = (15 * scale).sp, + ), + code = + TextStyle( + fontFamily = mono, + fontWeight = FontWeight.Normal, + fontSize = (12 * scale).sp, + lineHeight = (17 * scale).sp, + ), + ) +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/color/HarvestCircleColors.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/color/HarvestCircleColors.kt @@ -0,0 +1,667 @@ +package org.harvestcircle.designsystem.theme.color + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color + +/** + * Background and container colors. Names express hierarchy rather than a physical palette. + */ +@Immutable +public class HarvestCircleSurfaceColors internal constructor( + public val canvas: Color, + public val base: Color, + public val raised: Color, + public val sunken: Color, + public val selected: Color, + public val overlay: Color, + public val scrim: Color, +) + +/** Text and icon colors. */ +@Immutable +public class HarvestCircleContentColors internal constructor( + public val primary: Color, + public val secondary: Color, + public val muted: Color, + public val disabled: Color, + public val inverse: Color, +) + +/** Boundary colors, ordered by visual emphasis. */ +@Immutable +public class HarvestCircleBorderColors internal constructor( + public val subtle: Color, + public val default: Color, + public val strong: Color, + public val selected: Color, + public val error: Color, +) + +/** Colors for every state of an action treatment. */ +@Immutable +public class HarvestCircleActionStateColors internal constructor( + public val rest: Color, + public val hover: Color, + public val pressed: Color, + public val disabled: Color, + public val content: Color, + public val disabledContent: Color, + public val border: Color, +) + +/** Canonical action treatments. */ +@Immutable +public class HarvestCircleActionColors internal constructor( + public val primary: HarvestCircleActionStateColors, + public val secondary: HarvestCircleActionStateColors, + public val ghost: HarvestCircleActionStateColors, + public val destructive: HarvestCircleActionStateColors, +) + +/** A strong and subtle treatment for a feedback role. */ +@Immutable +public class HarvestCircleFeedbackRoleColors internal constructor( + public val strong: Color, + public val onStrong: Color, + public val subtle: Color, + public val onSubtle: Color, + public val border: Color, +) + +/** Semantic status colors. */ +@Immutable +public class HarvestCircleFeedbackColors internal constructor( + public val info: HarvestCircleFeedbackRoleColors, + public val success: HarvestCircleFeedbackRoleColors, + public val warning: HarvestCircleFeedbackRoleColors, + public val error: HarvestCircleFeedbackRoleColors, +) + +/** Selection and keyboard-focus colors. */ +@Immutable +public class HarvestCircleFocusColors internal constructor( + public val ring: Color, + public val selection: Color, + public val selectionContent: Color, +) + +/** + * The public, read-only semantic color contract. Physical palette values remain internal. + */ +@Immutable +public class HarvestCircleColors internal constructor( + public val isDark: Boolean, + public val isHighContrast: Boolean, + public val surface: HarvestCircleSurfaceColors, + public val content: HarvestCircleContentColors, + public val border: HarvestCircleBorderColors, + public val action: HarvestCircleActionColors, + public val feedback: HarvestCircleFeedbackColors, + public val focus: HarvestCircleFocusColors, +) + +internal object HarvestCirclePrimitiveColors { + val Transparent = Color.Transparent + + // Warm-neutral light surfaces. + val WarmWhite = Color(0xFFFFFEFC) + val Canvas = Color(0xFFF6F6F3) + val Surface = Color(0xFFFCFCFA) + val SurfaceSunken = Color(0xFFEEEEEA) + val Neutral100 = Color(0xFFE5E5E0) + val Neutral200 = Color(0xFFD7D7D1) + val Neutral300 = Color(0xFFC4C4BD) + val Neutral500 = Color(0xFF8B8B84) + val Neutral600 = Color(0xFF6A6A64) + val Neutral700 = Color(0xFF4A4A45) + val Neutral800 = Color(0xFF30302D) + val Neutral900 = Color(0xFF1C1C1A) + val Black = Color(0xFF000000) + val White = Color(0xFFFFFFFF) + + // Dark surfaces stay neutral instead of adopting a blue cast. + val DarkCanvas = Color(0xFF101110) + val DarkBase = Color(0xFF1A1B1A) + val DarkRaised = Color(0xFF252625) + val DarkSunken = Color(0xFF0B0C0B) + val DarkBorderSubtle = Color(0xFF353633) + val DarkBorder = Color(0xFF4B4C48) + val DarkBorderStrong = Color(0xFF777972) + val DarkContentPrimary = Color(0xFFF6F6F2) + val DarkContentSecondary = Color(0xFFD6D6D0) + val DarkContentMuted = Color(0xFFACACA5) + val DarkContentDisabled = Color(0xFF70706B) + + // An accessible, restrained system-blue family. + val Blue50 = Color(0xFFE8F2FC) + val Blue100 = Color(0xFFD6E9FC) + val Blue400 = Color(0xFF4A9BEA) + val Blue500 = Color(0xFF0066CC) + val Blue600 = Color(0xFF0059B3) + val Blue700 = Color(0xFF004C99) + val Blue800 = Color(0xFF003C7A) + val Blue900 = Color(0xFF002B59) + val BlueFocusDark = Color(0xFF69B4FF) + val BlueDarkSelected = Color(0xFF18334D) + + val Green50 = Color(0xFFE8F5ED) + val Green500 = Color(0xFF1F7A4D) + val Green700 = Color(0xFF135C38) + val GreenDark = Color(0xFF4FB987) + val GreenDarkSurface = Color(0xFF123426) + + val Amber50 = Color(0xFFFFF4D6) + val Amber500 = Color(0xFF8A5A00) + val Amber700 = Color(0xFF634000) + val AmberDark = Color(0xFFF2BA57) + val AmberDarkSurface = Color(0xFF3B2A0B) + + val Red50 = Color(0xFFFDECEA) + val Red500 = Color(0xFFB42318) + val Red600 = Color(0xFF982018) + val Red700 = Color(0xFF7A1A14) + val RedDark = Color(0xFFFF766F) + val RedDarkSurface = Color(0xFF401A18) + + val InfoDarkSurface = Color(0xFF122D46) +} + +private fun action( + rest: Color, + hover: Color, + pressed: Color, + disabled: Color, + content: Color, + disabledContent: Color, + border: Color, +): HarvestCircleActionStateColors = + HarvestCircleActionStateColors( + rest = rest, + hover = hover, + pressed = pressed, + disabled = disabled, + content = content, + disabledContent = disabledContent, + border = border, + ) + +private fun feedback( + strong: Color, + onStrong: Color, + subtle: Color, + onSubtle: Color, + border: Color, +): HarvestCircleFeedbackRoleColors = + HarvestCircleFeedbackRoleColors( + strong = strong, + onStrong = onStrong, + subtle = subtle, + onSubtle = onSubtle, + border = border, + ) + +internal object HarvestCircleColorSchemes { + val Light: HarvestCircleColors = + HarvestCircleColors( + isDark = false, + isHighContrast = false, + surface = + HarvestCircleSurfaceColors( + canvas = HarvestCirclePrimitiveColors.Canvas, + base = HarvestCirclePrimitiveColors.Surface, + raised = HarvestCirclePrimitiveColors.White, + sunken = HarvestCirclePrimitiveColors.SurfaceSunken, + selected = HarvestCirclePrimitiveColors.Blue50, + overlay = HarvestCirclePrimitiveColors.White, + scrim = Color(0x66000000), + ), + content = + HarvestCircleContentColors( + primary = HarvestCirclePrimitiveColors.Neutral900, + secondary = HarvestCirclePrimitiveColors.Neutral700, + muted = HarvestCirclePrimitiveColors.Neutral600, + disabled = HarvestCirclePrimitiveColors.Neutral500, + inverse = HarvestCirclePrimitiveColors.White, + ), + border = + HarvestCircleBorderColors( + subtle = HarvestCirclePrimitiveColors.Neutral100, + default = HarvestCirclePrimitiveColors.Neutral300, + strong = HarvestCirclePrimitiveColors.Neutral500, + selected = HarvestCirclePrimitiveColors.Blue500, + error = HarvestCirclePrimitiveColors.Red500, + ), + action = + HarvestCircleActionColors( + primary = + action( + rest = HarvestCirclePrimitiveColors.Blue500, + hover = HarvestCirclePrimitiveColors.Blue600, + pressed = HarvestCirclePrimitiveColors.Blue700, + disabled = HarvestCirclePrimitiveColors.Neutral200, + content = HarvestCirclePrimitiveColors.White, + disabledContent = HarvestCirclePrimitiveColors.Neutral600, + border = HarvestCirclePrimitiveColors.Blue500, + ), + secondary = + action( + rest = HarvestCirclePrimitiveColors.White, + hover = HarvestCirclePrimitiveColors.Canvas, + pressed = HarvestCirclePrimitiveColors.SurfaceSunken, + disabled = HarvestCirclePrimitiveColors.Surface, + content = HarvestCirclePrimitiveColors.Neutral900, + disabledContent = HarvestCirclePrimitiveColors.Neutral500, + border = HarvestCirclePrimitiveColors.Neutral300, + ), + ghost = + action( + rest = HarvestCirclePrimitiveColors.Transparent, + hover = HarvestCirclePrimitiveColors.SurfaceSunken, + pressed = HarvestCirclePrimitiveColors.Neutral100, + disabled = HarvestCirclePrimitiveColors.Transparent, + content = HarvestCirclePrimitiveColors.Neutral900, + disabledContent = HarvestCirclePrimitiveColors.Neutral500, + border = HarvestCirclePrimitiveColors.Transparent, + ), + destructive = + action( + rest = HarvestCirclePrimitiveColors.Red500, + hover = HarvestCirclePrimitiveColors.Red600, + pressed = HarvestCirclePrimitiveColors.Red700, + disabled = HarvestCirclePrimitiveColors.Neutral200, + content = HarvestCirclePrimitiveColors.White, + disabledContent = HarvestCirclePrimitiveColors.Neutral600, + border = HarvestCirclePrimitiveColors.Red500, + ), + ), + feedback = + HarvestCircleFeedbackColors( + info = + feedback( + strong = HarvestCirclePrimitiveColors.Blue500, + onStrong = HarvestCirclePrimitiveColors.White, + subtle = HarvestCirclePrimitiveColors.Blue50, + onSubtle = HarvestCirclePrimitiveColors.Blue800, + border = HarvestCirclePrimitiveColors.Blue400, + ), + success = + feedback( + strong = HarvestCirclePrimitiveColors.Green500, + onStrong = HarvestCirclePrimitiveColors.White, + subtle = HarvestCirclePrimitiveColors.Green50, + onSubtle = HarvestCirclePrimitiveColors.Green700, + border = HarvestCirclePrimitiveColors.Green500, + ), + warning = + feedback( + strong = HarvestCirclePrimitiveColors.Amber500, + onStrong = HarvestCirclePrimitiveColors.White, + subtle = HarvestCirclePrimitiveColors.Amber50, + onSubtle = HarvestCirclePrimitiveColors.Amber700, + border = HarvestCirclePrimitiveColors.Amber500, + ), + error = + feedback( + strong = HarvestCirclePrimitiveColors.Red500, + onStrong = HarvestCirclePrimitiveColors.White, + subtle = HarvestCirclePrimitiveColors.Red50, + onSubtle = HarvestCirclePrimitiveColors.Red700, + border = HarvestCirclePrimitiveColors.Red500, + ), + ), + focus = + HarvestCircleFocusColors( + ring = HarvestCirclePrimitiveColors.Blue500, + selection = HarvestCirclePrimitiveColors.Blue100, + selectionContent = HarvestCirclePrimitiveColors.Neutral900, + ), + ) + + val Dark: HarvestCircleColors = + HarvestCircleColors( + isDark = true, + isHighContrast = false, + surface = + HarvestCircleSurfaceColors( + canvas = HarvestCirclePrimitiveColors.DarkCanvas, + base = HarvestCirclePrimitiveColors.DarkBase, + raised = HarvestCirclePrimitiveColors.DarkRaised, + sunken = HarvestCirclePrimitiveColors.DarkSunken, + selected = HarvestCirclePrimitiveColors.BlueDarkSelected, + overlay = HarvestCirclePrimitiveColors.DarkRaised, + scrim = Color(0x99000000), + ), + content = + HarvestCircleContentColors( + primary = HarvestCirclePrimitiveColors.DarkContentPrimary, + secondary = HarvestCirclePrimitiveColors.DarkContentSecondary, + muted = HarvestCirclePrimitiveColors.DarkContentMuted, + disabled = HarvestCirclePrimitiveColors.DarkContentDisabled, + inverse = HarvestCirclePrimitiveColors.Neutral900, + ), + border = + HarvestCircleBorderColors( + subtle = HarvestCirclePrimitiveColors.DarkBorderSubtle, + default = HarvestCirclePrimitiveColors.DarkBorder, + strong = HarvestCirclePrimitiveColors.DarkBorderStrong, + selected = HarvestCirclePrimitiveColors.BlueFocusDark, + error = HarvestCirclePrimitiveColors.RedDark, + ), + action = + HarvestCircleActionColors( + primary = + action( + rest = HarvestCirclePrimitiveColors.Blue500, + hover = HarvestCirclePrimitiveColors.Blue400, + pressed = HarvestCirclePrimitiveColors.Blue600, + disabled = HarvestCirclePrimitiveColors.DarkBorderSubtle, + content = HarvestCirclePrimitiveColors.White, + disabledContent = HarvestCirclePrimitiveColors.DarkContentDisabled, + border = HarvestCirclePrimitiveColors.Blue500, + ), + secondary = + action( + rest = HarvestCirclePrimitiveColors.DarkRaised, + hover = Color(0xFF303230), + pressed = HarvestCirclePrimitiveColors.DarkBorderSubtle, + disabled = HarvestCirclePrimitiveColors.DarkBase, + content = HarvestCirclePrimitiveColors.DarkContentPrimary, + disabledContent = HarvestCirclePrimitiveColors.DarkContentDisabled, + border = HarvestCirclePrimitiveColors.DarkBorder, + ), + ghost = + action( + rest = HarvestCirclePrimitiveColors.Transparent, + hover = HarvestCirclePrimitiveColors.DarkRaised, + pressed = HarvestCirclePrimitiveColors.DarkBorderSubtle, + disabled = HarvestCirclePrimitiveColors.Transparent, + content = HarvestCirclePrimitiveColors.DarkContentPrimary, + disabledContent = HarvestCirclePrimitiveColors.DarkContentDisabled, + border = HarvestCirclePrimitiveColors.Transparent, + ), + destructive = + action( + rest = HarvestCirclePrimitiveColors.Red500, + hover = Color(0xFFC8362C), + pressed = HarvestCirclePrimitiveColors.Red600, + disabled = HarvestCirclePrimitiveColors.DarkBorderSubtle, + content = HarvestCirclePrimitiveColors.White, + disabledContent = HarvestCirclePrimitiveColors.DarkContentDisabled, + border = HarvestCirclePrimitiveColors.RedDark, + ), + ), + feedback = + HarvestCircleFeedbackColors( + info = + feedback( + strong = HarvestCirclePrimitiveColors.Blue400, + onStrong = HarvestCirclePrimitiveColors.Blue900, + subtle = HarvestCirclePrimitiveColors.InfoDarkSurface, + onSubtle = Color(0xFFB7DBFF), + border = HarvestCirclePrimitiveColors.Blue400, + ), + success = + feedback( + strong = HarvestCirclePrimitiveColors.GreenDark, + onStrong = Color(0xFF062518), + subtle = HarvestCirclePrimitiveColors.GreenDarkSurface, + onSubtle = Color(0xFFB6E5CC), + border = HarvestCirclePrimitiveColors.GreenDark, + ), + warning = + feedback( + strong = HarvestCirclePrimitiveColors.AmberDark, + onStrong = Color(0xFF2E1D00), + subtle = HarvestCirclePrimitiveColors.AmberDarkSurface, + onSubtle = Color(0xFFFFDFA1), + border = HarvestCirclePrimitiveColors.AmberDark, + ), + error = + feedback( + strong = HarvestCirclePrimitiveColors.RedDark, + onStrong = Color(0xFF310300), + subtle = HarvestCirclePrimitiveColors.RedDarkSurface, + onSubtle = Color(0xFFFFC3BF), + border = HarvestCirclePrimitiveColors.RedDark, + ), + ), + focus = + HarvestCircleFocusColors( + ring = HarvestCirclePrimitiveColors.BlueFocusDark, + selection = Color(0xFF204B72), + selectionContent = HarvestCirclePrimitiveColors.White, + ), + ) + + val LightHighContrast: HarvestCircleColors = + HarvestCircleColors( + isDark = false, + isHighContrast = true, + surface = + HarvestCircleSurfaceColors( + canvas = HarvestCirclePrimitiveColors.White, + base = HarvestCirclePrimitiveColors.White, + raised = HarvestCirclePrimitiveColors.White, + sunken = Color(0xFFF0F0EC), + selected = Color(0xFFDCEBFA), + overlay = HarvestCirclePrimitiveColors.White, + scrim = Color(0x99000000), + ), + content = + HarvestCircleContentColors( + primary = HarvestCirclePrimitiveColors.Black, + secondary = Color(0xFF1F1F1D), + muted = Color(0xFF41413D), + disabled = Color(0xFF666661), + inverse = HarvestCirclePrimitiveColors.White, + ), + border = + HarvestCircleBorderColors( + subtle = Color(0xFF85857E), + default = Color(0xFF4C4C47), + strong = HarvestCirclePrimitiveColors.Black, + selected = Color(0xFF004F9E), + error = Color(0xFF86170F), + ), + action = + HarvestCircleActionColors( + primary = + action( + rest = Color(0xFF004F9E), + hover = Color(0xFF003F80), + pressed = Color(0xFF002F61), + disabled = Color(0xFFD0D0CA), + content = HarvestCirclePrimitiveColors.White, + disabledContent = Color(0xFF555550), + border = Color(0xFF003A75), + ), + secondary = + action( + rest = HarvestCirclePrimitiveColors.White, + hover = Color(0xFFEFEFEC), + pressed = Color(0xFFDFDFDA), + disabled = HarvestCirclePrimitiveColors.White, + content = HarvestCirclePrimitiveColors.Black, + disabledContent = Color(0xFF666661), + border = HarvestCirclePrimitiveColors.Black, + ), + ghost = + action( + rest = HarvestCirclePrimitiveColors.Transparent, + hover = Color(0xFFEFEFEC), + pressed = Color(0xFFDFDFDA), + disabled = HarvestCirclePrimitiveColors.Transparent, + content = HarvestCirclePrimitiveColors.Black, + disabledContent = Color(0xFF666661), + border = HarvestCirclePrimitiveColors.Transparent, + ), + destructive = + action( + rest = Color(0xFF86170F), + hover = Color(0xFF6E120C), + pressed = Color(0xFF570E09), + disabled = Color(0xFFD0D0CA), + content = HarvestCirclePrimitiveColors.White, + disabledContent = Color(0xFF555550), + border = Color(0xFF68110B), + ), + ), + feedback = + HarvestCircleFeedbackColors( + info = + feedback( + strong = Color(0xFF004F9E), + onStrong = HarvestCirclePrimitiveColors.White, + subtle = Color(0xFFDCEBFA), + onSubtle = Color(0xFF002F61), + border = Color(0xFF003A75), + ), + success = + feedback( + strong = Color(0xFF145F3B), + onStrong = HarvestCirclePrimitiveColors.White, + subtle = Color(0xFFDDF2E6), + onSubtle = Color(0xFF0A3E25), + border = Color(0xFF0F4D30), + ), + warning = + feedback( + strong = Color(0xFF6A4300), + onStrong = HarvestCirclePrimitiveColors.White, + subtle = Color(0xFFFFF0C4), + onSubtle = Color(0xFF4B2E00), + border = Color(0xFF543500), + ), + error = + feedback( + strong = Color(0xFF86170F), + onStrong = HarvestCirclePrimitiveColors.White, + subtle = Color(0xFFF9DEDA), + onSubtle = Color(0xFF570E09), + border = Color(0xFF68110B), + ), + ), + focus = + HarvestCircleFocusColors( + ring = Color(0xFF003F80), + selection = Color(0xFFBBD9F6), + selectionContent = HarvestCirclePrimitiveColors.Black, + ), + ) + + val DarkHighContrast: HarvestCircleColors = + HarvestCircleColors( + isDark = true, + isHighContrast = true, + surface = + HarvestCircleSurfaceColors( + canvas = HarvestCirclePrimitiveColors.Black, + base = HarvestCirclePrimitiveColors.Black, + raised = Color(0xFF111210), + sunken = HarvestCirclePrimitiveColors.Black, + selected = Color(0xFF102D49), + overlay = Color(0xFF111210), + scrim = Color(0xCC000000), + ), + content = + HarvestCircleContentColors( + primary = HarvestCirclePrimitiveColors.White, + secondary = Color(0xFFF1F1ED), + muted = Color(0xFFD0D0CA), + disabled = Color(0xFF9A9A94), + inverse = HarvestCirclePrimitiveColors.Black, + ), + border = + HarvestCircleBorderColors( + subtle = Color(0xFF85857E), + default = Color(0xFFB0B0AA), + strong = HarvestCirclePrimitiveColors.White, + selected = Color(0xFF79BFFF), + error = Color(0xFFFF8E88), + ), + action = + HarvestCircleActionColors( + primary = + action( + rest = Color(0xFF0062C4), + hover = Color(0xFF1678DA), + pressed = Color(0xFF004E9D), + disabled = Color(0xFF2B2C29), + content = HarvestCirclePrimitiveColors.White, + disabledContent = Color(0xFF9A9A94), + border = Color(0xFF79BFFF), + ), + secondary = + action( + rest = Color(0xFF111210), + hover = Color(0xFF252622), + pressed = Color(0xFF353630), + disabled = HarvestCirclePrimitiveColors.Black, + content = HarvestCirclePrimitiveColors.White, + disabledContent = Color(0xFF9A9A94), + border = HarvestCirclePrimitiveColors.White, + ), + ghost = + action( + rest = HarvestCirclePrimitiveColors.Transparent, + hover = Color(0xFF252622), + pressed = Color(0xFF353630), + disabled = HarvestCirclePrimitiveColors.Transparent, + content = HarvestCirclePrimitiveColors.White, + disabledContent = Color(0xFF9A9A94), + border = HarvestCirclePrimitiveColors.Transparent, + ), + destructive = + action( + rest = Color(0xFF9E2118), + hover = Color(0xFFB42C22), + pressed = Color(0xFF7E1A13), + disabled = Color(0xFF2B2C29), + content = HarvestCirclePrimitiveColors.White, + disabledContent = Color(0xFF9A9A94), + border = Color(0xFFFF8E88), + ), + ), + feedback = + HarvestCircleFeedbackColors( + info = + feedback( + strong = Color(0xFF79BFFF), + onStrong = Color(0xFF001B34), + subtle = Color(0xFF102D49), + onSubtle = HarvestCirclePrimitiveColors.White, + border = Color(0xFF79BFFF), + ), + success = + feedback( + strong = Color(0xFF72D5A6), + onStrong = Color(0xFF002416), + subtle = Color(0xFF123426), + onSubtle = HarvestCirclePrimitiveColors.White, + border = Color(0xFF72D5A6), + ), + warning = + feedback( + strong = Color(0xFFFFCC73), + onStrong = Color(0xFF2C1B00), + subtle = Color(0xFF3B2A0B), + onSubtle = HarvestCirclePrimitiveColors.White, + border = Color(0xFFFFCC73), + ), + error = + feedback( + strong = Color(0xFFFF8E88), + onStrong = Color(0xFF300300), + subtle = Color(0xFF401A18), + onSubtle = HarvestCirclePrimitiveColors.White, + border = Color(0xFFFF8E88), + ), + ), + focus = + HarvestCircleFocusColors( + ring = Color(0xFF79BFFF), + selection = Color(0xFF244E75), + selectionContent = HarvestCirclePrimitiveColors.White, + ), + ) +} 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 @@ -0,0 +1,136 @@ +package org.harvestcircle.designsystem.theme + +import androidx.compose.ui.graphics.Color +import org.harvestcircle.designsystem.theme.color.HarvestCircleColorSchemes +import org.harvestcircle.designsystem.theme.color.HarvestCircleColors +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.test.Test +import kotlin.test.assertTrue + +class HarvestCircleColorContrastTest { + private val schemes = + listOf( + HarvestCircleColorSchemes.Light, + HarvestCircleColorSchemes.Dark, + HarvestCircleColorSchemes.LightHighContrast, + HarvestCircleColorSchemes.DarkHighContrast, + ) + + @Test + fun primaryContentMeetsNormalTextContrast() { + schemes.forEach { colors -> + assertContrast( + foreground = colors.content.primary, + background = colors.surface.canvas, + minimum = 4.5, + label = "${colors.description()} primary content on canvas", + ) + assertContrast( + foreground = colors.content.primary, + background = colors.surface.base, + minimum = 4.5, + label = "${colors.description()} primary content on base", + ) + } + } + + @Test + fun primaryAndDestructiveActionsMeetNormalTextContrast() { + schemes.forEach { colors -> + assertContrast( + foreground = colors.action.primary.content, + background = colors.action.primary.rest, + minimum = 4.5, + label = "${colors.description()} primary action", + ) + assertContrast( + foreground = colors.action.destructive.content, + background = colors.action.destructive.rest, + minimum = 4.5, + label = "${colors.description()} destructive action", + ) + } + } + + @Test + fun subtleFeedbackContentMeetsNormalTextContrast() { + schemes.forEach { colors -> + listOf( + "info" to colors.feedback.info, + "success" to colors.feedback.success, + "warning" to colors.feedback.warning, + "error" to colors.feedback.error, + ).forEach { (name, role) -> + assertContrast( + foreground = role.onSubtle, + background = role.subtle, + minimum = 4.5, + label = "${colors.description()} $name subtle feedback", + ) + } + } + } + + @Test + fun focusRingSeparatesFromPrimarySurfaces() { + schemes.forEach { colors -> + assertContrast( + foreground = colors.focus.ring, + background = colors.surface.base, + minimum = 3.0, + label = "${colors.description()} focus ring on base", + ) + assertContrast( + foreground = colors.focus.ring, + background = colors.surface.canvas, + minimum = 3.0, + label = "${colors.description()} focus ring on canvas", + ) + } + } +} + +private fun HarvestCircleColors.description(): String = + buildString { + append(if (isDark) "dark" else "light") + append(if (isHighContrast) " high contrast" else " standard") + } + +private fun assertContrast( + foreground: Color, + background: Color, + minimum: Double, + label: String, +) { + val actual = contrastRatio(foreground, background) + assertTrue( + actual >= minimum, + "$label contrast was ${actual.formatTwoDecimals()}, expected at least $minimum", + ) +} + +private fun contrastRatio( + first: Color, + second: Color, +): Double { + val firstLuminance = first.relativeLuminance() + val secondLuminance = second.relativeLuminance() + return (max(firstLuminance, secondLuminance) + 0.05) / + (min(firstLuminance, secondLuminance) + 0.05) +} + +private fun Color.relativeLuminance(): Double = + 0.2126 * red.toDouble().linearized() + + 0.7152 * green.toDouble().linearized() + + 0.0722 * blue.toDouble().linearized() + +private fun Double.linearized(): Double = + if (this <= 0.04045) { + this / 12.92 + } else { + ((this + 0.055) / 1.055).pow(2.4) + } + +private fun Double.formatTwoDecimals(): String = ((this * 100.0).toInt() / 100.0).toString() diff --git a/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleSystemAppearanceTest.kt b/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleSystemAppearanceTest.kt @@ -0,0 +1,47 @@ +package org.harvestcircle.designsystem.theme + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HarvestCircleSystemAppearanceTest { + @Test + fun systemModeTracksObservedLightAppearance() { + assertFalse( + resolveHarvestCircleDarkTheme( + mode = HarvestCircleThemeMode.System, + systemDark = false, + ), + ) + } + + @Test + fun systemModeTracksObservedDarkAppearance() { + assertTrue( + resolveHarvestCircleDarkTheme( + mode = HarvestCircleThemeMode.System, + systemDark = true, + ), + ) + } + + @Test + fun fixedLightModeIgnoresDarkSystemAppearance() { + assertFalse( + resolveHarvestCircleDarkTheme( + mode = HarvestCircleThemeMode.Light, + systemDark = true, + ), + ) + } + + @Test + fun fixedDarkModeIgnoresLightSystemAppearance() { + assertTrue( + resolveHarvestCircleDarkTheme( + mode = HarvestCircleThemeMode.Dark, + systemDark = false, + ), + ) + } +} diff --git a/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleTokenInvariantTest.kt b/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleTokenInvariantTest.kt @@ -0,0 +1,94 @@ +package org.harvestcircle.designsystem.theme + +import androidx.compose.ui.graphics.Color +import org.harvestcircle.designsystem.theme.color.HarvestCircleColorSchemes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +class HarvestCircleTokenInvariantTest { + @Test + fun semanticColorsAreSpecified() { + val schemes = + listOf( + HarvestCircleColorSchemes.Light, + HarvestCircleColorSchemes.Dark, + HarvestCircleColorSchemes.LightHighContrast, + HarvestCircleColorSchemes.DarkHighContrast, + ) + + schemes.forEach { colors -> + listOf( + colors.surface.canvas, + colors.surface.base, + colors.surface.raised, + colors.surface.sunken, + colors.surface.selected, + colors.content.primary, + colors.content.secondary, + colors.content.muted, + colors.border.subtle, + colors.border.default, + colors.border.strong, + colors.action.primary.rest, + colors.action.primary.content, + colors.action.destructive.rest, + colors.focus.ring, + colors.focus.selection, + ).forEach { color -> + assertNotEquals(Color.Unspecified, color) + } + } + } + + @Test + fun visualAndInteractionDimensionsRemainIntentional() { + HarvestCircleDensity.entries.forEach { density -> + val pointer = harvestCircleDimensions(density, HarvestCircleInputMode.Pointer) + val touch = harvestCircleDimensions(density, HarvestCircleInputMode.Touch) + + assertTrue(pointer.minimumInteractive.value >= 28f) + assertTrue(touch.minimumInteractive.value >= 48f) + assertTrue(pointer.controlSmall.value > 0f) + assertTrue(pointer.controlMedium.value >= pointer.controlSmall.value) + assertTrue(pointer.controlLarge.value >= pointer.controlMedium.value) + assertTrue(pointer.controlLarge.value <= 32f) + assertTrue(pointer.focusRingWidth.value >= 2f) + assertTrue(pointer.selectionControl.value in 13f..14f) + assertTrue(pointer.switchHeight.value in 16f..18f) + } + } + + @Test + fun typographyLineHeightsDoNotUndercutFontSizes() { + val typography = createHarvestCircleTypography() + + listOf( + typography.display, + typography.pageTitle, + typography.sectionTitle, + typography.subsectionTitle, + typography.body, + typography.bodyStrong, + typography.bodySmall, + typography.label, + typography.labelSmall, + typography.code, + ).forEach { style -> + assertTrue(style.lineHeight.value >= style.fontSize.value) + } + } + + @Test + fun productTextScalesAreBoundedAndDeterministic() { + val standard = createHarvestCircleTypography(scale = HarvestCircleTextScale.Standard.factor) + val large = createHarvestCircleTypography(scale = HarvestCircleTextScale.Large.factor) + val extraLarge = + createHarvestCircleTypography(scale = HarvestCircleTextScale.ExtraLarge.factor) + + assertEquals(13F, standard.body.fontSize.value) + assertEquals(14.95F, large.body.fontSize.value, absoluteTolerance = 0.001F) + assertEquals(16.9F, extraLarge.body.fontSize.value, absoluteTolerance = 0.001F) + } +} diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/DesktopDesignKmp.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/DesktopDesignKmp.kt @@ -27,7 +27,7 @@ internal fun Project.applyDesktopDesignKmp() { ) extension.filter { filter -> filter.exclude { element -> - element.file.invariantSeparatorsPath.contains("/build/generated/") + element.file.invariantSeparatorsPath.contains("/generated/compose/resourceGenerator/") } } } diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -330,6 +330,7 @@ fn namespace_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<String .replace("audit-only/product-copy", "") .replace("audit-only/web-target", "") .replace("fixture(\"design-source\")", "") + .replace("com.radroots.studio", "") .replace(r#"source_product = \"Studio\""#, "") .replace(&format!("\"{}\"", title_case(&legacy)), ""); } @@ -707,6 +708,9 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St if is_production_compose(path, &source) && path != "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleTheme.kt" + && !path.starts_with( + "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/color/", + ) && contains_direct_call(&compact, "Color(") { findings.push(format!( @@ -977,6 +981,23 @@ fn design_source_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St )); } } + for path in &inventory.paths { + if !path.starts_with("app/design_system/") && !path.starts_with("tools/design_catalog/") { + continue; + } + let lowercase = read_text(root, path).to_ascii_lowercase(); + for forbidden in [ + "androidx.compose.material3", + "io.github.kdroidfilter.platformtools", + "com.radroots.studio", + ] { + if lowercase.contains(forbidden) { + findings.push(format!( + "{path}: forbidden dependency or legacy namespace: {forbidden}" + )); + } + } + } for (path, sha256) in [ ( "app/design_system/src/commonMain/composeResources/font/inter_bold.ttf",