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:
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)
+ }
+}