commit 67ad9e5097ba8ac733c89b5fda18a6be70c26e50
parent 6a306a7b0c65a9eb5d76d0e1661e12f9a973468f
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 21:26:17 +0000
ui: apply the HarvestCircle theme
- Resolve System, Light, and Dark appearance through an injectable platform signal.
- Provide palette, typography, shape, motion, and text-scale composition locals.
- Apply themed backgrounds and surfaces across shell, dashboard, canvas, and overlays.
- Centralize Compose color conversion and reduced-motion availability.
Diffstat:
7 files changed, 164 insertions(+), 30 deletions(-)
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
@@ -1,5 +1,6 @@
package org.harvestcircle.ui.shell
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -30,11 +31,13 @@ fun CanvasScaffold(
val effectiveTextSize =
textSize.takeUnless { it == TextSizePreference.Default }
?: LocalShellAppearance.current.textSize
- Column(Modifier.fillMaxSize().testTag("canvas-scaffold")) {
+ val palette = LocalHarvestCirclePalette.current
+ Column(Modifier.fillMaxSize().background(palette.background.toComposeColor()).testTag("canvas-scaffold")) {
Row(
Modifier
.fillMaxWidth()
.height(64.dp)
+ .background(palette.surface.toComposeColor())
.semantics { contentDescription = "Canvas header" }
.testTag("canvas-header"),
verticalAlignment = Alignment.CenterVertically,
@@ -62,6 +65,7 @@ fun CanvasScaffold(
Modifier
.fillMaxWidth()
.height(72.dp)
+ .background(palette.surface.toComposeColor())
.semantics { contentDescription = "Canvas action bar" }
.testTag("canvas-action-bar"),
) {
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/DashboardScaffold.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/DashboardScaffold.kt
@@ -1,5 +1,6 @@
package org.harvestcircle.ui.shell
+import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -26,7 +27,8 @@ fun DashboardScaffold(
inspector: @Composable () -> Unit = {},
) {
val placement = inspectorPlacement(windowWidthDp, inspectorVisible)
- Column(Modifier.fillMaxSize().testTag("dashboard-scaffold")) {
+ val palette = LocalHarvestCirclePalette.current
+ Column(Modifier.fillMaxSize().background(palette.background.toComposeColor()).testTag("dashboard-scaffold")) {
Region("dashboard-top-bar", "Global top bar", Modifier.fillMaxWidth().height(56.dp), topBar)
Row(Modifier.fillMaxSize()) {
Region("dashboard-sidebar", "Workspace sidebar", Modifier.width(232.dp).fillMaxHeight(), sidebar)
@@ -56,7 +58,12 @@ private fun Region(
Box(
modifier =
modifier
- .semantics { contentDescription = label }
+ .background(
+ when (tag) {
+ "dashboard-main-body" -> LocalHarvestCirclePalette.current.background.toComposeColor()
+ else -> LocalHarvestCirclePalette.current.surface.toComposeColor()
+ },
+ ).semantics { contentDescription = label }
.testTag(tag),
) {
content()
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt
@@ -1,5 +1,6 @@
package org.harvestcircle.ui.shell
+import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -33,6 +34,7 @@ fun FoundationOverlayHost(
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
Modifier
+ .background(LocalHarvestCirclePalette.current.surface.toComposeColor())
.focusGroup()
.semantics { contentDescription = "Dialog" }
.testTag("foundation-overlay"),
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt
@@ -15,7 +15,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
@@ -23,9 +22,7 @@ import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
-import org.harvestcircle.design.ColorToken
import org.harvestcircle.design.HarvestCircleDesign
-import org.harvestcircle.design.ThemePreference
enum class SyncStatusLabel(
val text: String,
@@ -99,11 +96,7 @@ internal fun ShellAction(
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
- val focusColor =
- when (LocalShellAppearance.current.theme) {
- ThemePreference.Dark -> HarvestCircleDesign.dark.focus
- ThemePreference.System, ThemePreference.Light -> HarvestCircleDesign.light.focus
- }.composeColor()
+ val focusColor = LocalHarvestCirclePalette.current.focus.toComposeColor()
BasicText(
text = label,
modifier =
@@ -112,7 +105,7 @@ internal fun ShellAction(
.onFocusChanged { focused = it.isFocused }
.border(
width = HarvestCircleDesign.BORDER_DP.dp,
- color = if (focused) focusColor else Color.Transparent,
+ color = if (focused) focusColor else androidx.compose.ui.graphics.Color.Transparent,
).padding(horizontal = HarvestCircleDesign.spacingDp[2].dp)
.semantics {
contentDescription = description
@@ -122,8 +115,3 @@ internal fun ShellAction(
.testTag(tag),
)
}
-
-private fun ColorToken.composeColor(): Color {
- val rgb = hex.removePrefix("#").toLong(16)
- return Color(0xFF000000 or rgb)
-}
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
@@ -5,12 +5,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
-import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
-import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
-import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import org.harvestcircle.application.FoundationOverlay
import org.harvestcircle.application.HarvestCircleIntent
@@ -35,15 +31,7 @@ fun HarvestCircleShell(
platformActions: HarvestCirclePlatformActions,
dispatch: (HarvestCircleShellIntent) -> Unit,
) {
- val baseDensity = LocalDensity.current
- val scaledDensity =
- remember(baseDensity, state.appearance.textSize) {
- Density(baseDensity.density, baseDensity.fontScale * state.appearance.textSize.scale)
- }
- CompositionLocalProvider(
- LocalDensity provides scaledDensity,
- LocalShellAppearance provides state.appearance,
- ) {
+ HarvestCircleTheme(state.appearance) {
ShellKeyboardHost(onShortcut = { dispatchShortcut(it, dispatch) }) {
HarvestCircleShellContent(state, identityActions, platformActions, dispatch)
}
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
@@ -0,0 +1,113 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.Density
+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.ThemePreference
+import org.harvestcircle.design.TypographyToken
+
+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,
+ )
+}
+
+@Composable
+fun HarvestCircleTheme(
+ appearance: AppearanceState,
+ systemDark: Boolean = isSystemInDarkTheme(),
+ content: @Composable () -> Unit,
+) {
+ val tokens = harvestCircleThemeTokens(appearance, systemDark)
+ val baseDensity = LocalDensity.current
+ val scaledDensity = Density(baseDensity.density, baseDensity.fontScale * tokens.textScale)
+ CompositionLocalProvider(
+ LocalShellAppearance provides appearance,
+ LocalHarvestCirclePalette provides tokens.palette,
+ LocalHarvestCircleTypography provides tokens.typography,
+ LocalHarvestCircleShapes provides tokens.shapes,
+ LocalHarvestCircleMotion provides tokens.motion,
+ LocalDensity provides scaledDensity,
+ ) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .background(tokens.palette.background.toComposeColor()),
+ ) {
+ content()
+ }
+ }
+}
+
+fun ColorToken.toComposeColor(): Color {
+ val rgb = hex.removePrefix("#").toLong(16)
+ return Color(0xFF000000 or rgb)
+}
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,5 +1,8 @@
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
@@ -38,4 +41,33 @@ class HarvestCircleDesignTest {
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)
+ }
}