app

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

commit 1c39f050c41281351ae66ebf1c610af9efc1d836
parent 594f847eb00ea87848b1311fffbdd8b73301abe8
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 17:23:32 +0000

design: port the approved Studio shell controls

- render the application frame with Studio shell surfaces
- add exact shell button, icon, navigation, and tab anatomy
- add the compact Studio-derived command input
- allow explicit semantic tinting for owned vector icons

Diffstat:
Mapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleAppFrame.kt | 8++++++--
Mapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleIcon.kt | 3++-
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellControls.kt | 321+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 329 insertions(+), 3 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 @@ -20,6 +20,7 @@ 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.shell.HarvestCircleShellPalette import org.harvestcircle.designsystem.theme.HarvestCircleFrameMetrics import org.harvestcircle.designsystem.theme.HarvestCircleTheme @@ -129,9 +130,11 @@ public fun HarvestCircleAppFrame( ) { val frameMetrics = HarvestCircleTheme.shell.frame + val shellColors = HarvestCircleShellPalette HarvestCircleSurface( modifier = modifier.fillMaxSize(), role = HarvestCircleSurfaceRole.Canvas, + color = shellColors.viewportCanvas, ) { BoxWithConstraints( modifier = @@ -154,6 +157,7 @@ public fun HarvestCircleAppFrame( Modifier .width(geometry.sidebarWidth) .fillMaxHeight() + .background(shellColors.sidebar) .testTag("harvestcircle-sidebar"), ) { sidebar(geometry) @@ -237,7 +241,7 @@ private fun HarvestCircleFramePane( content: @Composable BoxScope.() -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.background(HarvestCircleTheme.foundation.colors.surface.base)) { + Column(modifier = modifier.background(HarvestCircleShellPalette.pane)) { Box( modifier = Modifier @@ -272,5 +276,5 @@ public fun HarvestCircleStructuralDivider( .fillMaxWidth() .height(HarvestCircleTheme.shell.frame.structuralDividerWidth) } - Box(dividerModifier.background(HarvestCircleTheme.foundation.colors.border.subtle)) + Box(dividerModifier.background(HarvestCircleShellPalette.divider)) } diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleIcon.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleIcon.kt @@ -40,11 +40,12 @@ public fun HarvestCircleIcon( modifier: Modifier = Modifier, size: HarvestCircleIconSize = HarvestCircleIconSize.Medium, tone: HarvestCircleContentTone = HarvestCircleContentTone.Inherit, + tint: Color? = null, ) { Image( painter = painterResource(resource), contentDescription = contentDescription, modifier = modifier.size(iconSize(size)), - colorFilter = ColorFilter.tint(iconColor(tone)), + colorFilter = ColorFilter.tint(tint ?: iconColor(tone)), ) } diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellControls.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellControls.kt @@ -0,0 +1,321 @@ +package org.harvestcircle.designsystem.shell + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.harvestcircle.designsystem.icon.HarvestCircleIcons +import org.harvestcircle.designsystem.primitive.HarvestCircleIcon +import org.harvestcircle.designsystem.theme.HarvestCircleTheme +import org.jetbrains.compose.resources.DrawableResource + +@Composable +public fun HarvestCircleShellIconButton( + onClick: () -> Unit, + icon: DrawableResource, + label: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, + selected: Boolean = false, + showBorder: Boolean = false, + controlSize: Dp = HarvestCircleShellMetrics.topBarSquareControlSize, + iconSize: Dp = HarvestCircleShellMetrics.topBarIconSize, +) { + val colors = HarvestCircleShellPalette + val sources = rememberHarvestCircleShellInteractionSources(label) + val interaction = sources.collectHarvestCircleShellInteractions(enabled) + val container = + when { + !enabled -> Color.Transparent + selected -> colors.navigationSelected + interaction.pressed -> colors.navigationPressed + interaction.hovered -> colors.navigationHover + else -> Color.Transparent + } + val shape = + androidx.compose.foundation.shape + .RoundedCornerShape(HarvestCircleShellMetrics.controlRadius) + Box( + modifier = + modifier + .size(controlSize) + .clip(shape) + .background(container) + .border(BorderStroke(1.dp, if (showBorder || selected) colors.border else Color.Transparent), shape) + .harvestCircleShellHoverable(sources, enabled) + .semantics { + contentDescription = label + role = Role.Button + if (!enabled) disabled() + }.then( + if (enabled) { + Modifier.clickable( + interactionSource = sources.activation, + indication = null, + role = Role.Button, + onClick = onClick, + ) + } else { + Modifier + }, + ), + contentAlignment = Alignment.Center, + ) { + HarvestCircleIcon( + resource = icon, + contentDescription = null, + modifier = Modifier.size(iconSize), + tint = if (enabled) colors.contentSecondary else colors.contentDisabled, + ) + } +} + +@Composable +public fun HarvestCircleShellButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + primary: Boolean = false, + destructive: Boolean = false, + enabled: Boolean = true, + leadingContent: (@Composable RowScope.() -> Unit)? = null, +) { + val colors = HarvestCircleShellPalette + val sources = rememberHarvestCircleShellInteractionSources(text) + val interaction = sources.collectHarvestCircleShellInteractions(enabled) + val filled = primary || destructive + val active = if (destructive) Color(0xFF982018) else colors.accent + val activeHover = if (destructive) Color(0xFFB42318) else colors.accentHover + val activePressed = if (destructive) Color(0xFF7A1A14) else colors.accentPressed + val background = + when { + !enabled -> colors.input + filled && interaction.pressed -> activePressed + filled && interaction.hovered -> activeHover + filled -> active + interaction.pressed -> colors.navigationPressed + interaction.hovered -> colors.navigationHover + else -> colors.raised + } + val foreground = + if (!enabled) { + colors.contentDisabled + } else if (filled) { + colors.onAccent + } else { + colors.contentPrimary + } + val shape = + androidx.compose.foundation.shape + .RoundedCornerShape(HarvestCircleShellMetrics.controlRadius) + Row( + modifier = + modifier + .height(32.dp) + .clip(shape) + .background(background) + .border(BorderStroke(1.dp, if (filled) background else colors.border), shape) + .harvestCircleShellHoverable(sources, enabled) + .semantics { + role = Role.Button + contentDescription = text + if (!enabled) disabled() + }.then( + if (enabled) { + Modifier.clickable( + interactionSource = sources.activation, + indication = null, + role = Role.Button, + onClick = onClick, + ) + } else { + Modifier + }, + ).padding(horizontal = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + leadingContent?.invoke(this) + HarvestCircleShellText(text, role = HarvestCircleShellTextRole.Label, color = foreground, maxLines = 1) + } +} + +@Composable +public fun HarvestCircleShellNavigationItem( + label: String, + icon: DrawableResource, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + compact: Boolean = false, +) { + val colors = HarvestCircleShellPalette + val sources = rememberHarvestCircleShellInteractionSources("navigation:$label") + val interaction = sources.collectHarvestCircleShellInteractions(enabled) + val background = + when { + selected -> colors.navigationSelected + interaction.pressed -> colors.navigationPressed + interaction.hovered -> colors.navigationHover + else -> Color.Transparent + } + val content = if (enabled) colors.contentSecondary else colors.contentDisabled + val shape = + androidx.compose.foundation.shape + .RoundedCornerShape(HarvestCircleShellMetrics.navigationRadius) + Row( + modifier = + modifier + .fillMaxWidth() + .height(HarvestCircleShellMetrics.sidebarNavigationRowHeight) + .clip(shape) + .background(background) + .harvestCircleShellHoverable(sources, enabled) + .semantics { if (!enabled) disabled() } + .then( + if (enabled) Modifier.selectable(selected, sources.activation, null, true, Role.Tab, onClick) else Modifier, + ).padding(horizontal = HarvestCircleShellMetrics.sidebarNavigationHorizontalPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (compact) Arrangement.Center else Arrangement.Start, + ) { + HarvestCircleIcon( + icon, + if (compact) label else null, + Modifier.size(HarvestCircleShellMetrics.sidebarNavigationIconSize), + tint = content, + ) + if (!compact) { + Spacer(Modifier.width(HarvestCircleShellMetrics.sidebarNavigationIconGap)) + HarvestCircleShellText( + label, + Modifier.weight(1f), + if (selected) HarvestCircleShellTextRole.BodyStrong else HarvestCircleShellTextRole.Body, + if (selected) colors.contentPrimary else content, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +public fun HarvestCircleShellTab( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + icon: DrawableResource? = null, +) { + val colors = HarvestCircleShellPalette + val sources = rememberHarvestCircleShellInteractionSources("tab:$label") + val interaction = sources.collectHarvestCircleShellInteractions() + val shape = + androidx.compose.foundation.shape + .RoundedCornerShape(HarvestCircleShellMetrics.navigationRadius) + val background = + when { + selected -> colors.navigationSelected + interaction.pressed -> colors.navigationPressed + interaction.hovered -> colors.navigationHover + else -> Color.Transparent + } + Row( + modifier = + modifier + .defaultMinSize(minWidth = 88.dp) + .height(32.dp) + .clip(shape) + .background(background) + .border(BorderStroke(1.dp, if (selected) colors.border else Color.Transparent), shape) + .harvestCircleShellHoverable(sources) + .selectable(selected, sources.activation, null, true, Role.Tab, onClick) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + icon?.let { HarvestCircleIcon(it, null, Modifier.size(16.dp), tint = colors.contentSecondary) } + HarvestCircleShellText( + label, + role = if (selected) HarvestCircleShellTextRole.BodyStrong else HarvestCircleShellTextRole.Body, + maxLines = 1, + ) + } +} + +@Composable +public fun HarvestCircleShellSearchField( + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + modifier: Modifier = Modifier, + compact: Boolean = false, + visualTransformation: VisualTransformation = VisualTransformation.None, +) { + val colors = HarvestCircleShellPalette + val sources = rememberHarvestCircleShellInteractionSources("shell-search") + val interaction = sources.collectHarvestCircleShellInteractions() + val shape = + androidx.compose.foundation.shape + .RoundedCornerShape(HarvestCircleShellMetrics.controlRadius) + Row( + modifier = + modifier + .width(if (compact) HarvestCircleShellMetrics.topBarSearchCompactWidth else HarvestCircleShellMetrics.topBarSearchWidth) + .height(32.dp) + .clip(shape) + .background(colors.input) + .border(BorderStroke(1.dp, if (interaction.focused) colors.accent else colors.border), shape) + .padding(horizontal = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + HarvestCircleIcon(HarvestCircleIcons.Search, null, Modifier.size(15.dp), tint = colors.contentMuted) + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + textStyle = + HarvestCircleTheme.foundation.typography.body + .copy(color = colors.contentPrimary), + interactionSource = sources.activation, + cursorBrush = SolidColor(colors.accent), + visualTransformation = visualTransformation, + singleLine = true, + decorationBox = { inner -> + Box(contentAlignment = Alignment.CenterStart) { + if (value.isEmpty()) HarvestCircleShellText(placeholder, color = colors.contentMuted, maxLines = 1) + inner() + } + }, + ) + } +}