commit 594f847eb00ea87848b1311fffbdd8b73301abe8
parent ae2677b73b3c4d3f134027b35dfffe6862dc85ec
Author: triesap <tyson@radroots.org>
Date: Thu, 13 Aug 2026 17:18:04 +0000
design: own the approved Studio shell foundation
- pin the approved light and dark shell palettes
- expose the Studio-derived application geometry grid
- add shell-specific text roles and interaction state
- verify exact palette and breakpoint invariants
Diffstat:
4 files changed, 257 insertions(+), 0 deletions(-)
diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellInteraction.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellInteraction.kt
@@ -0,0 +1,45 @@
+package org.harvestcircle.designsystem.shell
+
+import androidx.compose.foundation.hoverable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+
+@Stable
+public class HarvestCircleShellInteractionSources internal constructor(
+ public val activation: MutableInteractionSource,
+ public val hover: MutableInteractionSource,
+)
+
+@Immutable
+public data class HarvestCircleShellInteractions(
+ public val hovered: Boolean,
+ public val pressed: Boolean,
+ public val focused: Boolean,
+)
+
+@Composable
+public fun rememberHarvestCircleShellInteractionSources(key: Any? = Unit): HarvestCircleShellInteractionSources =
+ remember(key) { HarvestCircleShellInteractionSources(MutableInteractionSource(), MutableInteractionSource()) }
+
+@Composable
+public fun HarvestCircleShellInteractionSources.collectHarvestCircleShellInteractions(
+ enabled: Boolean = true,
+): HarvestCircleShellInteractions {
+ val hovered by hover.collectIsHoveredAsState()
+ val pressed by activation.collectIsPressedAsState()
+ val focused by activation.collectIsFocusedAsState()
+ return HarvestCircleShellInteractions(enabled && hovered, enabled && pressed, enabled && focused)
+}
+
+public fun Modifier.harvestCircleShellHoverable(
+ sources: HarvestCircleShellInteractionSources,
+ enabled: Boolean = true,
+): Modifier = hoverable(sources.hover, enabled)
diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellText.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellText.kt
@@ -0,0 +1,44 @@
+package org.harvestcircle.designsystem.shell
+
+import androidx.compose.foundation.text.BasicText
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import org.harvestcircle.designsystem.theme.HarvestCircleTheme
+
+public enum class HarvestCircleShellTextRole {
+ PageTitle,
+ SectionTitle,
+ PaneTitle,
+ Body,
+ BodyStrong,
+ Small,
+ Label,
+ Code,
+}
+
+@Composable
+public fun HarvestCircleShellText(
+ text: String,
+ modifier: Modifier = Modifier,
+ role: HarvestCircleShellTextRole = HarvestCircleShellTextRole.Body,
+ color: Color = HarvestCircleShellPalette.contentPrimary,
+ maxLines: Int = Int.MAX_VALUE,
+ overflow: TextOverflow = TextOverflow.Clip,
+) {
+ val typography = HarvestCircleTheme.foundation.typography
+ val base =
+ when (role) {
+ HarvestCircleShellTextRole.PageTitle -> typography.pageTitle
+ HarvestCircleShellTextRole.SectionTitle -> typography.sectionTitle
+ HarvestCircleShellTextRole.PaneTitle -> typography.bodyStrong.copy(fontWeight = FontWeight.SemiBold)
+ HarvestCircleShellTextRole.Body -> typography.body
+ HarvestCircleShellTextRole.BodyStrong -> typography.bodyStrong
+ HarvestCircleShellTextRole.Small -> typography.bodySmall
+ HarvestCircleShellTextRole.Label -> typography.label
+ HarvestCircleShellTextRole.Code -> typography.code
+ }
+ BasicText(text, modifier, base.copy(color = color), maxLines = maxLines, overflow = overflow)
+}
diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellVisuals.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellVisuals.kt
@@ -0,0 +1,137 @@
+package org.harvestcircle.designsystem.shell
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import org.harvestcircle.designsystem.theme.HarvestCircleTheme
+
+/** Approved Studio-derived semantic palette for HarvestCircle application chrome. */
+@Immutable
+public data class HarvestCircleShellColors(
+ public val isDark: Boolean,
+ public val viewportCanvas: Color,
+ public val applicationFrame: Color,
+ public val sidebar: Color,
+ public val pane: Color,
+ public val raised: Color,
+ public val input: Color,
+ public val navigationHover: Color,
+ public val navigationPressed: Color,
+ public val navigationSelected: Color,
+ public val divider: Color,
+ public val border: Color,
+ public val contentPrimary: Color,
+ public val contentSecondary: Color,
+ public val contentMuted: Color,
+ public val contentDisabled: Color,
+ public val accent: Color,
+ public val accentHover: Color,
+ public val accentPressed: Color,
+ public val accentSubtle: Color,
+ public val onAccent: Color,
+ public val shadow: Color,
+)
+
+private val HarvestCircleShellLightColors =
+ HarvestCircleShellColors(
+ isDark = false,
+ viewportCanvas = Color(0xFFD9D9D7),
+ applicationFrame = Color(0xFFFFFFFF),
+ sidebar = Color(0xFFF6F6F4),
+ pane = Color(0xFFFFFFFF),
+ raised = Color(0xFFFFFFFF),
+ input = Color(0xFFF3F3F1),
+ navigationHover = Color(0xFFF0F0ED),
+ navigationPressed = Color(0xFFE8E8E4),
+ navigationSelected = Color(0xFFECECEA),
+ divider = Color(0xFFE5E6E9),
+ border = Color(0xFFE5E6E9),
+ contentPrimary = Color(0xFF141517),
+ contentSecondary = Color(0xFF626560),
+ contentMuted = Color(0xFF858984),
+ contentDisabled = Color(0xFFA7AAA6),
+ accent = Color(0xFF155239),
+ accentHover = Color(0xFF1B6547),
+ accentPressed = Color(0xFF10432F),
+ accentSubtle = Color(0xFFE6EFEA),
+ onAccent = Color.White,
+ shadow = Color(0x2E000000),
+ )
+
+private val HarvestCircleShellDarkColors =
+ HarvestCircleShellColors(
+ isDark = true,
+ viewportCanvas = Color(0xFF0E100F),
+ applicationFrame = Color(0xFF171917),
+ sidebar = Color(0xFF1B1D1B),
+ pane = Color(0xFF1E201E),
+ raised = Color(0xFF242624),
+ input = Color(0xFF222422),
+ navigationHover = Color(0xFF252825),
+ navigationPressed = Color(0xFF2D302D),
+ navigationSelected = Color(0xFF292C29),
+ divider = Color(0xFF30332F),
+ border = Color(0xFF343834),
+ contentPrimary = Color(0xFFF1F2EF),
+ contentSecondary = Color(0xFFB5BAB5),
+ contentMuted = Color(0xFF878D87),
+ contentDisabled = Color(0xFF666B66),
+ accent = Color(0xFF155239),
+ accentHover = Color(0xFF1D6849),
+ accentPressed = Color(0xFF10432F),
+ accentSubtle = Color(0xFF173126),
+ onAccent = Color.White,
+ shadow = Color(0x66000000),
+ )
+
+public fun harvestCircleShellColors(isDark: Boolean): HarvestCircleShellColors =
+ if (isDark) HarvestCircleShellDarkColors else HarvestCircleShellLightColors
+
+public val HarvestCircleShellPalette: HarvestCircleShellColors
+ @Composable
+ @ReadOnlyComposable
+ get() = harvestCircleShellColors(HarvestCircleTheme.foundation.colors.isDark)
+
+/** Fixed application-shell geometry derived from the approved Studio frames. */
+public object HarvestCircleShellMetrics {
+ public val topBarHeight: Dp = 48.dp
+ public val localHeaderHeight: Dp = 40.dp
+ public val structuralDividerWidth: Dp = 1.dp
+ public val sidebarWidth: Dp = 232.dp
+ public val collapsedSidebarWidth: Dp = 72.dp
+ public val sidebarHorizontalInset: Dp = 16.dp
+ public val sidebarHeaderHeight: Dp = 48.dp
+ public val sidebarHeaderIconTarget: Dp = 28.dp
+ public val sidebarHeaderIconSize: Dp = 15.dp
+ public val sidebarQuickActionHeight: Dp = 32.dp
+ public val sidebarQuickActionBottomGap: Dp = 8.dp
+ public val sidebarSectionHeaderHeight: Dp = 28.dp
+ public val sidebarSectionGap: Dp = 14.dp
+ public val sidebarNavigationRowHeight: Dp = 32.dp
+ public val sidebarNavigationGap: Dp = 2.dp
+ public val sidebarNavigationHorizontalPadding: Dp = 8.dp
+ public val sidebarNavigationIconSize: Dp = 16.dp
+ public val sidebarNavigationIconGap: Dp = 10.dp
+ public val sidebarFooterOuterInset: Dp = 12.dp
+ public val sidebarFooterHeight: Dp = 36.dp
+ public val sidebarFooterIconSize: Dp = 18.dp
+ public val topBarHorizontalInset: Dp = 12.dp
+ public val topBarControlGap: Dp = 8.dp
+ public val topBarSquareControlSize: Dp = 32.dp
+ public val topBarIconSize: Dp = 16.dp
+ public val topBarSearchWidth: Dp = 280.dp
+ public val topBarSearchCompactWidth: Dp = 176.dp
+ public val localHeaderHorizontalInset: Dp = 12.dp
+ public val localHeaderActionSize: Dp = 28.dp
+ public val localHeaderGap: Dp = 8.dp
+ public val controlRadius: Dp = 10.dp
+ public val navigationRadius: Dp = 8.dp
+ public val contentPageHorizontalInset: Dp = 24.dp
+ public val contentPageVerticalInset: Dp = 24.dp
+ public val emptyStateWidth: Dp = 344.dp
+ public val compactBreakpoint: Dp = 976.dp
+ public val expandedBreakpoint: Dp = 1272.dp
+}
diff --git a/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellVisualsTest.kt b/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellVisualsTest.kt
@@ -0,0 +1,31 @@
+package org.harvestcircle.designsystem.shell
+
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+
+class HarvestCircleShellVisualsTest {
+ @Test
+ fun palettePinsApprovedStudioValues() {
+ val light = harvestCircleShellColors(false)
+ val dark = harvestCircleShellColors(true)
+ assertEquals(Color(0xFF155239), light.accent)
+ assertEquals(Color(0xFFF6F6F4), light.sidebar)
+ assertEquals(Color(0xFFECECEA), light.navigationSelected)
+ assertEquals(Color(0xFF141517), light.contentPrimary)
+ assertEquals(Color(0xFF155239), dark.accent)
+ assertNotEquals(light.applicationFrame, dark.applicationFrame)
+ }
+
+ @Test
+ fun geometryPinsApprovedStudioGrid() {
+ assertEquals(48.dp, HarvestCircleShellMetrics.topBarHeight)
+ assertEquals(40.dp, HarvestCircleShellMetrics.localHeaderHeight)
+ assertEquals(232.dp, HarvestCircleShellMetrics.sidebarWidth)
+ assertEquals(72.dp, HarvestCircleShellMetrics.collapsedSidebarWidth)
+ assertEquals(976.dp, HarvestCircleShellMetrics.compactBreakpoint)
+ assertEquals(1272.dp, HarvestCircleShellMetrics.expandedBreakpoint)
+ }
+}