app

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

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:
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleAppFrame.kt | 276+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleMetrics.kt | 35+++++++++++++++++++++++++++++++++++
Mapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleThemeSnapshot.kt | 2++
Aapp/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleAppFrameTest.kt | 43+++++++++++++++++++++++++++++++++++++++++++
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) + } +}