app

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

commit e10269970580dfa8ec6b6ac2c44bbba84bc2063e
parent 26b8653fd6ef466ae0233f2a2dfd8eb4d7b0e3a5
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 15:59:43 +0000

ui: migrate bootstrap and lifecycle canvases

- own centered canvas geometry and scrolling in the design system
- apply semantic frame metrics to bootstrap and lifecycle regions
- preserve secret-entry focus, custody copy, and recovery actions
- verify all shell and design-system tests through the capsule check

Diffstat:
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleCanvasFrame.kt | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/theme/HarvestCircleMetrics.kt | 6++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt | 14++++++++++----
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt | 13++++++++-----
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/CanvasScaffold.kt | 83+++++++++++++++++++++++++++++++++----------------------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 6+++---
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LifecycleCanvases.kt | 6+++---
7 files changed, 148 insertions(+), 63 deletions(-)

diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleCanvasFrame.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleCanvasFrame.kt @@ -0,0 +1,83 @@ +package org.harvestcircle.designsystem.layout + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.HarvestCircleSurface +import org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole +import org.harvestcircle.designsystem.theme.HarvestCircleTheme + +/** Centered bootstrap and lifecycle frame with fixed chrome and one explicit scroll owner. */ +@Composable +public fun HarvestCircleCanvasFrame( + header: @Composable () -> Unit, + body: @Composable () -> Unit, + actionBar: @Composable () -> Unit, + modifier: Modifier = Modifier, + bodyModifier: Modifier = Modifier, + navigation: @Composable () -> Unit = {}, + step: @Composable () -> Unit = {}, + bodyScrollable: Boolean = false, +) { + val frame = HarvestCircleTheme.shell.frame + HarvestCircleSurface(modifier = modifier.fillMaxSize(), role = HarvestCircleSurfaceRole.Canvas) { + Column(Modifier.fillMaxSize()) { + HarvestCircleSurface( + modifier = Modifier.fillMaxWidth().height(frame.canvasHeaderHeight), + role = HarvestCircleSurfaceRole.Raised, + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = HarvestCircleTheme.shell.layout.pageInset), + verticalAlignment = Alignment.CenterVertically, + ) { + Box { navigation() } + Box(Modifier.weight(1f)) { header() } + Box { step() } + } + } + HarvestCircleHorizontalDivider() + val scrollModifier = + if (bodyScrollable) { + Modifier.verticalScroll(rememberScrollState()) + } else { + Modifier + } + Box( + modifier = bodyModifier.weight(1f).fillMaxWidth().then(scrollModifier), + contentAlignment = Alignment.TopCenter, + ) { + Box( + Modifier + .fillMaxWidth() + .widthIn(max = frame.canvasContentMaxWidth) + .padding(HarvestCircleTheme.shell.layout.pageInset), + ) { + body() + } + } + HarvestCircleHorizontalDivider() + HarvestCircleSurface( + modifier = Modifier.fillMaxWidth().height(frame.canvasActionBarHeight), + role = HarvestCircleSurfaceRole.Raised, + ) { + Box( + modifier = Modifier.fillMaxSize().padding(horizontal = HarvestCircleTheme.shell.layout.pageInset), + contentAlignment = Alignment.CenterEnd, + ) { + actionBar() + } + } + } + } +} 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 @@ -71,6 +71,9 @@ public class HarvestCircleFrameMetrics internal constructor( public val mainPaneMinimumWidth: Dp, public val compactBreakpoint: Dp, public val expandedBreakpoint: Dp, + public val canvasHeaderHeight: Dp, + public val canvasActionBarHeight: Dp, + public val canvasContentMaxWidth: Dp, ) /** Shadow elevations. Surfaces remain mostly flat, as in canonical AppKit windows. */ @@ -203,6 +206,9 @@ internal val HarvestCircleDefaultFrameMetrics: HarvestCircleFrameMetrics = mainPaneMinimumWidth = 480.dp, compactBreakpoint = 976.dp, expandedBreakpoint = 1272.dp, + canvasHeaderHeight = 64.dp, + canvasActionBarHeight = 72.dp, + canvasContentMaxWidth = 960.dp, ) internal fun harvestCircleMotion(mode: HarvestCircleMotionMode): HarvestCircleMotion = diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt @@ -14,8 +14,8 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.password import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.unit.dp import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.designsystem.theme.HarvestCircleTheme import org.harvestcircle.identities.ui.HarvestCircleUiActions import org.harvestcircle.identities.ui.HarvestCircleUiModel import org.harvestcircle.navigation.BootstrapStep @@ -49,7 +49,7 @@ fun BootstrapIdentityEntry( } }, actionBar = { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { if (step == BootstrapStep.CreateIdentity) { ShellAction("Generate identity", "Generate identity", "generate-key", enabled = !model.busy) { actions.generateIdentity() @@ -67,7 +67,10 @@ fun BootstrapIdentityEntry( @Composable private fun CreateIdentityBody(model: HarvestCircleUiModel) { - Column(Modifier.testTag("create-identity-entry"), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column( + Modifier.testTag("create-identity-entry"), + verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap), + ) { ShellText("HarvestCircle will generate a new Nostr identity.") ShellText("Save the recovery key before the identity is stored in the operating-system keyring.") model.problem?.let { ShellText(it, Modifier.testTag("identity-entry-problem")) } @@ -80,7 +83,10 @@ private fun ImportIdentityBody( actions: HarvestCircleUiActions, ) { val requester = remember { FocusRequester() } - Column(Modifier.testTag("import-identity-entry"), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column( + Modifier.testTag("import-identity-entry"), + verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap), + ) { ShellTextField( value = model.importDraft.revealForDisplay(), onValueChange = actions.editImportDraft, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt @@ -11,9 +11,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp import org.harvestcircle.application.ShellFocusTarget import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.designsystem.theme.HarvestCircleTheme import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleUiActions import org.harvestcircle.identities.ui.HarvestCircleUiModel @@ -30,7 +30,10 @@ fun GeneratedRecoveryCanvas( textSize = TextSizePreference.Default, header = { ShellText("Save your recovery key", textRole = ShellTextRole.ScreenTitle) }, body = { - Column(Modifier.testTag("generated-key-backup"), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column( + Modifier.testTag("generated-key-backup"), + verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap), + ) { ShellText("This key is shown once.") ShellText("Store it somewhere private before continuing.") ShellText("Recovery key", textRole = ShellTextRole.CardTitle) @@ -38,7 +41,7 @@ fun GeneratedRecoveryCanvas( } }, actionBar = { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { ShellAction("Copy recovery key", "Copy recovery key", "copy-generated-key") { platformActions.copySecret(backup.nsec) } @@ -70,7 +73,7 @@ fun IdentityChooserCanvas( } }, actionBar = { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { ShellAction("Create another identity", "Create another identity", "choose-create-identity") { actions.chooseCreateIdentity() } @@ -94,7 +97,7 @@ private fun IdentityRow( .fillMaxWidth() .semantics { selected = identity.selected } .testTag("identity-row:${identity.publicKeyHex}"), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap), ) { ShellText(identity.label, textRole = ShellTextRole.CardTitle) ShellText(identity.shortNpub, textRole = ShellTextRole.Protocol) diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/CanvasScaffold.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/CanvasScaffold.kt @@ -1,23 +1,14 @@ package org.harvestcircle.ui.shell -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.unit.dp import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.designsystem.layout.HarvestCircleCanvasFrame @Composable fun CanvasScaffold( @@ -31,45 +22,41 @@ fun CanvasScaffold( val effectiveTextSize = textSize.takeUnless { it == TextSizePreference.Default } ?: LocalShellAppearance.current.textSize - val palette = LocalHarvestCirclePalette.current - Column(Modifier.fillMaxSize().background(palette.background.toComposeColor()).testTag("canvas-scaffold")) { - Row( - Modifier - .fillMaxWidth() - .height(64.dp) - .background(palette.surface.toComposeColor()) - .semantics { contentDescription = "Canvas header" } - .testTag("canvas-header"), - verticalAlignment = Alignment.CenterVertically, - ) { + HarvestCircleCanvasFrame( + modifier = Modifier.fillMaxSize().testTag("canvas-scaffold"), + navigation = { Box(Modifier.testTag("canvas-navigation")) { navigation() } - Box(Modifier.weight(1f)) { header() } + }, + header = { + Box( + Modifier + .fillMaxSize() + .semantics { contentDescription = "Canvas header" } + .testTag("canvas-header"), + ) { + header() + } + }, + step = { Box(Modifier.testTag("canvas-step")) { step() } - } - val bodyModifier = - Modifier - .weight(1f) - .fillMaxWidth() - .let { modifier -> - if (canvasBodyScroll(effectiveTextSize) == ScrollOwnership.CanvasBodyAccessibility) { - modifier.verticalScroll(rememberScrollState()) - } else { - modifier - } - }.semantics { contentDescription = "Canvas body" } - .testTag("canvas-body") - Box(bodyModifier, contentAlignment = Alignment.TopCenter) { - Box(Modifier.fillMaxWidth().widthIn(max = 960.dp)) { body() } - } - Box( + }, + bodyScrollable = canvasBodyScroll(effectiveTextSize) == ScrollOwnership.CanvasBodyAccessibility, + bodyModifier = Modifier - .fillMaxWidth() - .height(72.dp) - .background(palette.surface.toComposeColor()) - .semantics { contentDescription = "Canvas action bar" } - .testTag("canvas-action-bar"), - ) { - actionBar() - } - } + .semantics { contentDescription = "Canvas body" } + .testTag("canvas-body"), + body = { + body() + }, + actionBar = { + Box( + Modifier + .fillMaxSize() + .semantics { contentDescription = "Canvas action bar" } + .testTag("canvas-action-bar"), + ) { + actionBar() + } + }, + ) } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt @@ -12,7 +12,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.unit.dp import org.harvestcircle.application.FoundationOverlay import org.harvestcircle.application.HarvestCircleIntent import org.harvestcircle.application.HarvestCircleShellIntent @@ -30,6 +29,7 @@ import org.harvestcircle.navigation.BootstrapStep import org.harvestcircle.navigation.NavigationIntent import org.harvestcircle.navigation.SettingsSection import org.harvestcircle.product.ScreenKey +import org.harvestcircle.designsystem.theme.HarvestCircleTheme as HarvestCircleDesignTokens @Composable fun HarvestCircleShell( @@ -119,7 +119,7 @@ private fun BootstrapWelcome(dispatch: (HarvestCircleShellIntent) -> Unit) { body = { Column( Modifier.testTag("bootstrap-welcome"), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(HarvestCircleDesignTokens.shell.layout.contentGap), ) { ShellText("Coordinate local food with clear, signed terms.", textRole = ShellTextRole.SectionTitle) ShellText("HarvestCircle helps farms and nearby buyers form one shared order.") @@ -134,7 +134,7 @@ private fun BootstrapWelcome(dispatch: (HarvestCircleShellIntent) -> Unit) { } }, actionBar = { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleDesignTokens.shell.layout.inlineGap)) { ShellAction("Explore read-only", "Explore read-only", "bootstrap-read-only") { dispatch(HarvestCircleShellIntent.EnterReadOnly) } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LifecycleCanvases.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LifecycleCanvases.kt @@ -8,11 +8,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.unit.dp import org.harvestcircle.application.HarvestCirclePresenterState import org.harvestcircle.application.HarvestCircleRoute import org.harvestcircle.design.AppearanceState import org.harvestcircle.design.TextSizePreference +import org.harvestcircle.designsystem.theme.HarvestCircleTheme import org.harvestcircle.identities.ui.HarvestCircleUiActions @Composable @@ -27,14 +27,14 @@ fun ShellLifecycleCanvas( body = { Column( Modifier.testTag("lifecycle-${state.route.name.lowercase()}"), - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap), ) { ShellText(presentation.detail) state.problem?.let { ShellText(it, Modifier.testTag("lifecycle-problem")) } } }, actionBar = { - Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { if (state.lastProblem?.retryable == true) { ShellButton("Retry", "Retry the last local operation", actions.retryLastCommand) }