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