app

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

commit 68a6f5812b347ec7af9872d4afe3587516206650
parent 824dfdc2cbc8bf281d7413f70598c08ab6bf6bbd
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 15:33:36 +0000

design: port HarvestCircle layout system

- own pane sidebar toolbar and responsive layout primitives
- port pointer keyboard and semantic split-pane resizing
- keep breakpoints based on available space instead of platform identity
- prove split-pane state clamps every mutation to safe bounds

Diffstat:
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCirclePane.kt | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleResponsive.kt | 36++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleSidebar.kt | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleSplitPane.kt | 204+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleToolbar.kt | 44++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleSplitPaneStateTest.kt | 24++++++++++++++++++++++++
6 files changed, 418 insertions(+), 0 deletions(-)

diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCirclePane.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCirclePane.kt @@ -0,0 +1,55 @@ +package org.harvestcircle.designsystem.layout + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import org.harvestcircle.designsystem.primitive.HarvestCircleSurface +import org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole +import org.harvestcircle.designsystem.theme.HarvestCircleTheme + +/** + * Generic editor, inspector, or navigation pane. + * + * macOS application panes are rectangular by default; [rounded] is reserved for nested panels. + */ +@Composable +public fun HarvestCirclePane( + modifier: Modifier = Modifier, + role: HarvestCircleSurfaceRole = HarvestCircleSurfaceRole.Base, + padded: Boolean = true, + outlined: Boolean = false, + rounded: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + HarvestCircleSurface( + modifier = modifier, + role = role, + shape = if (rounded) HarvestCircleTheme.foundation.shapes.panel else RectangleShape, + border = + if (outlined) { + BorderStroke( + width = HarvestCircleTheme.shell.dimensions.dividerWidth, + color = HarvestCircleTheme.foundation.colors.border.subtle, + ) + } else { + null + }, + ) { + Column( + modifier = + if (padded) { + Modifier + .fillMaxSize() + .padding(HarvestCircleTheme.shell.layout.paneInset) + } else { + Modifier.fillMaxSize() + }, + content = content, + ) + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleResponsive.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleResponsive.kt @@ -0,0 +1,36 @@ +package org.harvestcircle.designsystem.layout + +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.BoxWithConstraintsScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** Layout class based on available width rather than platform identity. */ +public enum class HarvestCircleWindowWidthClass { + Narrow, + Medium, + Wide, +} + +/** + * Classifies the local constraints and passes the result to [content]. + * + * Breakpoints are product-neutral and intentionally based on available space, not desktop/web. + */ +@Composable +public fun HarvestCircleResponsive( + modifier: Modifier = Modifier, + content: @Composable BoxWithConstraintsScope.(HarvestCircleWindowWidthClass) -> Unit, +) { + BoxWithConstraints(modifier = modifier) { + val widthClass = + when { + maxWidth < 720.dp -> HarvestCircleWindowWidthClass.Narrow + maxWidth < 1120.dp -> HarvestCircleWindowWidthClass.Medium + else -> HarvestCircleWindowWidthClass.Wide + } + + content(widthClass) + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleSidebar.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleSidebar.kt @@ -0,0 +1,55 @@ +package org.harvestcircle.designsystem.layout + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.component.utility.HarvestCircleVerticalDivider +import org.harvestcircle.designsystem.primitive.HarvestCircleSurface +import org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole +import org.harvestcircle.designsystem.primitive.HarvestCircleText +import org.harvestcircle.designsystem.theme.HarvestCircleTheme + +/** Canonical source-list/sidebar surface for navigation and project browsers. */ +@Composable +public fun HarvestCircleSidebar( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + HarvestCircleSurface( + modifier = modifier.fillMaxHeight(), + role = HarvestCircleSurfaceRole.Sunken, + ) { + Column( + modifier = Modifier.padding(HarvestCircleTheme.foundation.spacing.sm), + content = content, + ) + HarvestCircleVerticalDivider(modifier = Modifier.align(Alignment.CenterEnd)) + } +} + +/** Quiet uppercase section label used inside [HarvestCircleSidebar]. */ +@Composable +public fun HarvestCircleSidebarSectionHeader( + text: String, + modifier: Modifier = Modifier, +) { + HarvestCircleText( + text = text.uppercase(), + modifier = + modifier.padding( + start = HarvestCircleTheme.foundation.spacing.md, + top = HarvestCircleTheme.foundation.spacing.lg, + end = HarvestCircleTheme.foundation.spacing.md, + bottom = HarvestCircleTheme.foundation.spacing.xs, + ), + role = HarvestCircleTextRole.LabelSmall, + tone = HarvestCircleContentTone.Muted, + maxLines = 1, + ) +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleSplitPane.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleSplitPane.kt @@ -0,0 +1,204 @@ +package org.harvestcircle.designsystem.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.setProgress +import androidx.compose.ui.unit.dp +import org.harvestcircle.designsystem.ExperimentalHarvestCircleUiApi +import org.harvestcircle.designsystem.component.utility.HarvestCircleVerticalDivider +import org.harvestcircle.designsystem.theme.HarvestCircleTheme + +/** Hoisted state for [HarvestCircleHorizontalSplitPane]. */ +@ExperimentalHarvestCircleUiApi +@Stable +public class HarvestCircleSplitPaneState internal constructor( + initialFraction: Float, + public val minimumFraction: Float, + public val maximumFraction: Float, +) { + private var mutableFraction: Float by + mutableFloatStateOf(initialFraction.coerceIn(minimumFraction, maximumFraction)) + + public val fraction: Float + get() = mutableFraction + + public fun setFraction(value: Float) { + mutableFraction = value.coerceIn(minimumFraction, maximumFraction) + } + + internal fun offsetBy(delta: Float) { + setFraction(fraction + delta) + } + + internal companion object { + fun saver( + minimumFraction: Float, + maximumFraction: Float, + ): Saver<HarvestCircleSplitPaneState, Float> = + Saver( + save = { it.fraction }, + restore = { + HarvestCircleSplitPaneState( + initialFraction = it, + minimumFraction = minimumFraction, + maximumFraction = maximumFraction, + ) + }, + ) + } +} + +/** Creates saveable split-pane state. */ +@ExperimentalHarvestCircleUiApi +@Composable +public fun rememberHarvestCircleSplitPaneState( + initialFraction: Float = 0.32f, + minimumFraction: Float = 0.18f, + maximumFraction: Float = 0.82f, +): HarvestCircleSplitPaneState { + require(minimumFraction in 0f..1f) + require(maximumFraction in 0f..1f) + require(minimumFraction < maximumFraction) + + return rememberSaveable( + minimumFraction, + maximumFraction, + saver = HarvestCircleSplitPaneState.saver(minimumFraction, maximumFraction), + ) { + HarvestCircleSplitPaneState( + initialFraction = initialFraction, + minimumFraction = minimumFraction, + maximumFraction = maximumFraction, + ) + } +} + +/** + * Accessible horizontal split pane supporting pointer drag, arrow keys, and semantic progress + * adjustment. + */ +@ExperimentalHarvestCircleUiApi +@Composable +public fun HarvestCircleHorizontalSplitPane( + dividerLabel: String, + modifier: Modifier = Modifier, + state: HarvestCircleSplitPaneState = rememberHarvestCircleSplitPaneState(), + first: @Composable BoxScope.() -> Unit, + second: @Composable BoxScope.() -> Unit, +) { + BoxWithConstraints(modifier = modifier) { + val density = LocalDensity.current + val availableWidthPx = with(density) { maxWidth.toPx() }.coerceAtLeast(1f) + var dividerFocused by remember { mutableStateOf(false) } + + Row(Modifier.fillMaxSize()) { + Box( + modifier = + Modifier + .weight(state.fraction) + .fillMaxHeight(), + content = first, + ) + + Box( + modifier = + Modifier + .width(8.dp) + .fillMaxHeight() + .background( + if (dividerFocused) { + HarvestCircleTheme.foundation.colors.surface.selected + } else { + HarvestCircleTheme.foundation.colors.surface.base + }, + ).semantics { + contentDescription = dividerLabel + progressBarRangeInfo = + ProgressBarRangeInfo( + current = state.fraction, + range = state.minimumFraction..state.maximumFraction, + steps = 0, + ) + setProgress { requested -> + state.setFraction(requested) + true + } + }.onFocusChanged { + dividerFocused = it.isFocused + }.focusable() + .onKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) { + return@onKeyEvent false + } + + when (event.key) { + Key.DirectionLeft -> { + state.offsetBy(-0.04f) + true + } + + Key.DirectionRight -> { + state.offsetBy(0.04f) + true + } + + else -> false + } + }.pointerInput(availableWidthPx) { + detectDragGestures { change, dragAmount -> + change.consume() + state.offsetBy(dragAmount.x / availableWidthPx) + } + }, + contentAlignment = Alignment.Center, + ) { + HarvestCircleVerticalDivider( + modifier = + Modifier + .width(HarvestCircleTheme.shell.dimensions.dividerWidth) + .fillMaxHeight(), + strong = dividerFocused, + ) + } + + Box( + modifier = + Modifier + .weight(1f - state.fraction) + .fillMaxHeight(), + content = second, + ) + } + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleToolbar.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleToolbar.kt @@ -0,0 +1,44 @@ +package org.harvestcircle.designsystem.layout + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import org.harvestcircle.designsystem.component.utility.HarvestCircleHorizontalDivider +import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor +import org.harvestcircle.designsystem.theme.HarvestCircleTheme + +/** Flat unified-toolbar surface with a single bottom separator, matching canonical macOS windows. */ +@Composable +public fun HarvestCircleToolbar( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .background(HarvestCircleTheme.foundation.colors.surface.raised), + ) { + ProvideHarvestCircleContentColor(HarvestCircleTheme.foundation.colors.content.primary) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(HarvestCircleTheme.shell.dimensions.toolbarHeight) + .padding(horizontal = HarvestCircleTheme.shell.layout.paneInset), + horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.sm), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) + } + HarvestCircleHorizontalDivider() + } +} diff --git a/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleSplitPaneStateTest.kt b/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleSplitPaneStateTest.kt @@ -0,0 +1,24 @@ +package org.harvestcircle.designsystem.layout + +import org.harvestcircle.designsystem.ExperimentalHarvestCircleUiApi +import kotlin.test.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalHarvestCircleUiApi::class) +class HarvestCircleSplitPaneStateTest { + @Test + fun fractionIsClampedAtConstructionAndMutationBoundaries() { + val state = + HarvestCircleSplitPaneState( + initialFraction = 0.05f, + minimumFraction = 0.2f, + maximumFraction = 0.8f, + ) + + assertEquals(0.2f, state.fraction) + + state.setFraction(0.95f) + + assertEquals(0.8f, state.fraction) + } +}