commit 4fd184d71d22f61512773f02d47eb26decfc6075
parent 38cef450cffa4bdc9b4ebd0bc6604ceb204c3d31
Author: triesap <tyson@radroots.org>
Date: Thu, 13 Aug 2026 15:38:11 +0000
design: add the stateless HarvestCircle frame
- own the approved sidebar top-bar and pane topology as layout slots
- keep navigation state copy and actions outside the design module
- adapt optional panes deterministically to available desktop width
- prove compact and expanded frame admission behavior
Diffstat:
4 files changed, 356 insertions(+), 0 deletions(-)
diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleAppFrame.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleAppFrame.kt
@@ -0,0 +1,276 @@
+package org.harvestcircle.designsystem.layout
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import org.harvestcircle.designsystem.primitive.HarvestCircleSurface
+import org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole
+import org.harvestcircle.designsystem.theme.HarvestCircleFrameMetrics
+import org.harvestcircle.designsystem.theme.HarvestCircleTheme
+
+public enum class HarvestCircleFrameLayoutClass {
+ Compact,
+ Medium,
+ Expanded,
+}
+
+public enum class HarvestCirclePaneWidth {
+ Narrow,
+ Standard,
+ Wide,
+ Utility,
+ Inspector,
+}
+
+@Immutable
+public data class HarvestCircleFrameGeometry(
+ public val layoutClass: HarvestCircleFrameLayoutClass,
+ public val sidebarWidth: Dp,
+ public val showSecondaryPane: Boolean,
+ public val showUtilityPane: Boolean,
+)
+
+public class HarvestCirclePaneSlot(
+ public val width: HarvestCirclePaneWidth,
+ public val header: @Composable () -> Unit,
+ public val content: @Composable BoxScope.() -> Unit,
+)
+
+internal fun HarvestCirclePaneWidth.resolve(metrics: HarvestCircleFrameMetrics): Dp =
+ when (this) {
+ HarvestCirclePaneWidth.Narrow -> metrics.narrowPaneWidth
+ HarvestCirclePaneWidth.Standard -> metrics.standardPaneWidth
+ HarvestCirclePaneWidth.Wide -> metrics.widePaneWidth
+ HarvestCirclePaneWidth.Utility -> metrics.utilityPaneWidth
+ HarvestCirclePaneWidth.Inspector -> metrics.inspectorPaneWidth
+ }
+
+public fun resolveHarvestCircleFrameGeometry(
+ width: Dp,
+ sidebarCollapsed: Boolean,
+ metrics: HarvestCircleFrameMetrics,
+ secondaryWidth: HarvestCirclePaneWidth? = null,
+ utilityWidth: HarvestCirclePaneWidth? = null,
+): HarvestCircleFrameGeometry {
+ val layoutClass =
+ when {
+ width < metrics.compactBreakpoint -> HarvestCircleFrameLayoutClass.Compact
+ width < metrics.expandedBreakpoint -> HarvestCircleFrameLayoutClass.Medium
+ else -> HarvestCircleFrameLayoutClass.Expanded
+ }
+ val sidebarWidth =
+ if (sidebarCollapsed || layoutClass == HarvestCircleFrameLayoutClass.Compact) {
+ metrics.collapsedSidebarWidth
+ } else {
+ metrics.sidebarWidth
+ }
+
+ fun fits(
+ includeSecondary: Boolean,
+ includeUtility: Boolean,
+ ): Boolean {
+ val paneDividerCount = (if (includeSecondary) 1 else 0) + (if (includeUtility) 1 else 0)
+ val dividerWidth = metrics.structuralDividerWidth
+ val requiredWidth =
+ sidebarWidth +
+ dividerWidth +
+ metrics.mainPaneMinimumWidth +
+ (if (includeSecondary) secondaryWidth?.resolve(metrics) ?: 0.dp else 0.dp) +
+ (if (includeUtility) utilityWidth?.resolve(metrics) ?: 0.dp else 0.dp) +
+ dividerWidth * paneDividerCount
+ return requiredWidth <= width
+ }
+
+ val secondaryRequested = secondaryWidth != null
+ val utilityRequested = utilityWidth != null
+ return when (layoutClass) {
+ HarvestCircleFrameLayoutClass.Compact ->
+ HarvestCircleFrameGeometry(layoutClass, sidebarWidth, false, false)
+
+ HarvestCircleFrameLayoutClass.Medium -> {
+ val showSecondary = secondaryRequested && fits(true, false)
+ val showUtility = !showSecondary && utilityRequested && fits(false, true)
+ HarvestCircleFrameGeometry(layoutClass, sidebarWidth, showSecondary, showUtility)
+ }
+
+ HarvestCircleFrameLayoutClass.Expanded -> {
+ val showSecondary = secondaryRequested && fits(true, false)
+ val showUtility = utilityRequested && fits(showSecondary, true)
+ HarvestCircleFrameGeometry(layoutClass, sidebarWidth, showSecondary, showUtility)
+ }
+ }
+}
+
+@Composable
+public fun HarvestCircleAppFrame(
+ sidebarCollapsed: Boolean,
+ sidebar: @Composable (HarvestCircleFrameGeometry) -> Unit,
+ topBar: @Composable (HarvestCircleFrameGeometry) -> Unit,
+ mainHeader: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ secondaryPane: HarvestCirclePaneSlot? = null,
+ utilityPane: HarvestCirclePaneSlot? = null,
+ mainContent: @Composable BoxScope.() -> Unit,
+) {
+ val frameMetrics = HarvestCircleTheme.shell.frame
+
+ HarvestCircleSurface(
+ modifier = modifier.fillMaxSize(),
+ role = HarvestCircleSurfaceRole.Canvas,
+ ) {
+ BoxWithConstraints(
+ modifier =
+ Modifier
+ .fillMaxSize()
+ .testTag("harvestcircle-frame"),
+ ) {
+ val geometry =
+ resolveHarvestCircleFrameGeometry(
+ width = maxWidth,
+ sidebarCollapsed = sidebarCollapsed,
+ metrics = frameMetrics,
+ secondaryWidth = secondaryPane?.width,
+ utilityWidth = utilityPane?.width,
+ )
+
+ Row(Modifier.fillMaxSize()) {
+ Box(
+ modifier =
+ Modifier
+ .width(geometry.sidebarWidth)
+ .fillMaxHeight()
+ .testTag("harvestcircle-sidebar"),
+ ) {
+ sidebar(geometry)
+ }
+ HarvestCircleStructuralDivider(vertical = true)
+ Column(
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(frameMetrics.topBarHeight)
+ .testTag("harvestcircle-top-bar"),
+ ) {
+ topBar(geometry)
+ }
+ HarvestCircleStructuralDivider(vertical = false)
+ Row(
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxSize(),
+ ) {
+ if (geometry.showSecondaryPane && secondaryPane != null) {
+ HarvestCircleFramePane(
+ modifier =
+ Modifier
+ .width(secondaryPane.width.resolve(frameMetrics))
+ .fillMaxHeight()
+ .testTag("harvestcircle-secondary-pane"),
+ slot = secondaryPane,
+ )
+ HarvestCircleStructuralDivider(vertical = true)
+ }
+ HarvestCircleFramePane(
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ .widthIn(min = frameMetrics.mainPaneMinimumWidth)
+ .testTag("harvestcircle-main-pane"),
+ header = mainHeader,
+ content = mainContent,
+ )
+ if (geometry.showUtilityPane && utilityPane != null) {
+ HarvestCircleStructuralDivider(vertical = true)
+ HarvestCircleFramePane(
+ modifier =
+ Modifier
+ .width(utilityPane.width.resolve(frameMetrics))
+ .fillMaxHeight()
+ .testTag("harvestcircle-utility-pane"),
+ slot = utilityPane,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun HarvestCircleFramePane(
+ slot: HarvestCirclePaneSlot,
+ modifier: Modifier = Modifier,
+): Unit =
+ HarvestCircleFramePane(
+ header = slot.header,
+ content = slot.content,
+ modifier = modifier,
+ )
+
+@Composable
+private fun HarvestCircleFramePane(
+ header: @Composable () -> Unit,
+ content: @Composable BoxScope.() -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier = modifier.background(HarvestCircleTheme.foundation.colors.surface.base)) {
+ Box(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .height(HarvestCircleTheme.shell.frame.localHeaderHeight),
+ ) {
+ header()
+ }
+ HarvestCircleStructuralDivider(vertical = false)
+ Box(
+ modifier =
+ Modifier
+ .weight(1f)
+ .fillMaxSize(),
+ content = content,
+ )
+ }
+}
+
+@Composable
+public fun HarvestCircleStructuralDivider(
+ vertical: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val dividerModifier =
+ if (vertical) {
+ modifier
+ .width(HarvestCircleTheme.shell.frame.structuralDividerWidth)
+ .fillMaxHeight()
+ } else {
+ modifier
+ .fillMaxWidth()
+ .height(HarvestCircleTheme.shell.frame.structuralDividerWidth)
+ }
+ Box(dividerModifier.background(HarvestCircleTheme.foundation.colors.border.subtle))
+}
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
@@ -55,6 +55,24 @@ public class HarvestCircleDimensions internal constructor(
public val menuItemHeight: Dp,
)
+/** Reference-derived geometry for the desktop application frame. */
+@Immutable
+public class HarvestCircleFrameMetrics internal constructor(
+ public val structuralDividerWidth: Dp,
+ public val topBarHeight: Dp,
+ public val localHeaderHeight: Dp,
+ public val sidebarWidth: Dp,
+ public val collapsedSidebarWidth: Dp,
+ public val narrowPaneWidth: Dp,
+ public val standardPaneWidth: Dp,
+ public val widePaneWidth: Dp,
+ public val utilityPaneWidth: Dp,
+ public val inspectorPaneWidth: Dp,
+ public val mainPaneMinimumWidth: Dp,
+ public val compactBreakpoint: Dp,
+ public val expandedBreakpoint: Dp,
+)
+
/** Shadow elevations. Surfaces remain mostly flat, as in canonical AppKit windows. */
@Immutable
public class HarvestCircleElevations internal constructor(
@@ -170,6 +188,23 @@ internal val HarvestCircleDefaultElevations: HarvestCircleElevations =
dialog = 18.dp,
)
+internal val HarvestCircleDefaultFrameMetrics: HarvestCircleFrameMetrics =
+ HarvestCircleFrameMetrics(
+ structuralDividerWidth = 1.dp,
+ topBarHeight = 48.dp,
+ localHeaderHeight = 40.dp,
+ sidebarWidth = 232.dp,
+ collapsedSidebarWidth = 72.dp,
+ narrowPaneWidth = 168.dp,
+ standardPaneWidth = 296.dp,
+ widePaneWidth = 344.dp,
+ utilityPaneWidth = 296.dp,
+ inspectorPaneWidth = 320.dp,
+ mainPaneMinimumWidth = 480.dp,
+ compactBreakpoint = 976.dp,
+ expandedBreakpoint = 1272.dp,
+ )
+
internal fun harvestCircleMotion(mode: HarvestCircleMotionMode): HarvestCircleMotion =
when (mode) {
HarvestCircleMotionMode.Full ->
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
@@ -17,6 +17,7 @@ public class HarvestCircleFoundationTokens internal constructor(
public class HarvestCircleShellTokens internal constructor(
public val layout: HarvestCircleLayout,
public val dimensions: HarvestCircleDimensions,
+ public val frame: HarvestCircleFrameMetrics,
)
@Immutable
@@ -60,6 +61,7 @@ internal fun createHarvestCircleThemeSnapshot(
HarvestCircleShellTokens(
layout = harvestCircleLayout(config.density),
dimensions = harvestCircleDimensions(config.density, config.inputMode),
+ frame = HarvestCircleDefaultFrameMetrics,
),
component =
HarvestCircleComponentTokens(
diff --git a/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleAppFrameTest.kt b/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleAppFrameTest.kt
@@ -0,0 +1,43 @@
+package org.harvestcircle.designsystem.layout
+
+import androidx.compose.ui.unit.dp
+import org.harvestcircle.designsystem.theme.HarvestCircleDefaultFrameMetrics
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+class HarvestCircleAppFrameTest {
+ @Test
+ fun compactFrameCollapsesSidebarAndHidesOptionalPanes() {
+ val geometry =
+ resolveHarvestCircleFrameGeometry(
+ width = 900.dp,
+ sidebarCollapsed = false,
+ metrics = HarvestCircleDefaultFrameMetrics,
+ secondaryWidth = HarvestCirclePaneWidth.Standard,
+ utilityWidth = HarvestCirclePaneWidth.Utility,
+ )
+
+ assertEquals(HarvestCircleFrameLayoutClass.Compact, geometry.layoutClass)
+ assertEquals(HarvestCircleDefaultFrameMetrics.collapsedSidebarWidth, geometry.sidebarWidth)
+ assertFalse(geometry.showSecondaryPane)
+ assertFalse(geometry.showUtilityPane)
+ }
+
+ @Test
+ fun expandedFrameAdmitsBothPanesOnlyWhenTheyFit() {
+ val geometry =
+ resolveHarvestCircleFrameGeometry(
+ width = 1500.dp,
+ sidebarCollapsed = false,
+ metrics = HarvestCircleDefaultFrameMetrics,
+ secondaryWidth = HarvestCirclePaneWidth.Standard,
+ utilityWidth = HarvestCirclePaneWidth.Utility,
+ )
+
+ assertEquals(HarvestCircleFrameLayoutClass.Expanded, geometry.layoutClass)
+ assertTrue(geometry.showSecondaryPane)
+ assertTrue(geometry.showUtilityPane)
+ }
+}