commit b3d5ebfaf2ebb5813edfd4b2dcc3580e5d5910ae
parent 9dd4f257d8231790ca6230cb3d190c14dbcc96d6
Author: triesap <tyson@radroots.org>
Date: Thu, 13 Aug 2026 16:04:46 +0000
ui: migrate HarvestCircle overlays and status banners
- add an owned dialog frame for product-specific interaction flows
- render global status through semantic design-system banner tones
- preserve modal focus cycles, busy-state gating, and typed actions
- verify overlay behavior and the complete standalone capsule check
Diffstat:
2 files changed, 138 insertions(+), 115 deletions(-)
diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/container/HarvestCircleDialog.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/container/HarvestCircleDialog.kt
@@ -3,6 +3,7 @@ package org.harvestcircle.designsystem.component.container
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -31,6 +32,34 @@ import org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole
import org.harvestcircle.designsystem.primitive.HarvestCircleText
import org.harvestcircle.designsystem.theme.HarvestCircleTheme
+/** Generic dialog frame for product-owned forms and interaction flows. */
+@Composable
+public fun HarvestCircleDialogFrame(
+ onDismissRequest: () -> Unit,
+ title: String,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Dialog(onDismissRequest = onDismissRequest) {
+ HarvestCircleSurface(
+ modifier =
+ modifier
+ .widthIn(min = 360.dp, max = 480.dp)
+ .semantics { paneTitle = title },
+ role = HarvestCircleSurfaceRole.Overlay,
+ shape = HarvestCircleTheme.foundation.shapes.dialog,
+ border = BorderStroke(1.dp, HarvestCircleTheme.foundation.colors.border.subtle),
+ shadowElevation = HarvestCircleTheme.component.elevations.dialog,
+ ) {
+ Column(
+ modifier = Modifier.padding(HarvestCircleTheme.foundation.spacing.xl),
+ verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.lg),
+ content = content,
+ )
+ }
+ }
+}
+
/** Canonical macOS alert sheet/panel treatment implemented with Compose Dialog primitives. */
@Composable
public fun HarvestCircleDialog(
@@ -55,94 +84,83 @@ public fun HarvestCircleDialog(
val defaultFocusRequester = remember { FocusRequester() }
val focusDismiss = destructive && dismissLabel != null
- Dialog(onDismissRequest = onDismissRequest) {
- val focusManager = LocalFocusManager.current
- LaunchedEffect(defaultFocusRequester, focusDismiss) {
- defaultFocusRequester.requestFocus()
+ val focusManager = LocalFocusManager.current
+ LaunchedEffect(defaultFocusRequester, focusDismiss) {
+ defaultFocusRequester.requestFocus()
+ }
+
+ HarvestCircleDialogFrame(
+ onDismissRequest = onDismissRequest,
+ title = title,
+ modifier =
+ modifier.harvestCircleClearFocusOnBackgroundPress(
+ focusManager = focusManager,
+ enabled =
+ focusDismissBehavior ==
+ HarvestCircleFocusDismissBehavior.ClearOnBackgroundPress,
+ force = forceFocusDismissal,
+ ),
+ ) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.sm),
+ ) {
+ HarvestCircleText(
+ text = title,
+ role = HarvestCircleTextRole.SectionTitle,
+ )
+ HarvestCircleText(
+ text = message,
+ role = HarvestCircleTextRole.Body,
+ tone = HarvestCircleContentTone.Secondary,
+ )
}
- HarvestCircleSurface(
- modifier =
- modifier
- .widthIn(min = 360.dp, max = 480.dp)
- .harvestCircleClearFocusOnBackgroundPress(
- focusManager = focusManager,
- enabled =
- focusDismissBehavior ==
- HarvestCircleFocusDismissBehavior.ClearOnBackgroundPress,
- force = forceFocusDismissal,
- ).semantics { paneTitle = title },
- role = HarvestCircleSurfaceRole.Overlay,
- shape = HarvestCircleTheme.foundation.shapes.dialog,
- border = BorderStroke(1.dp, HarvestCircleTheme.foundation.colors.border.subtle),
- shadowElevation = HarvestCircleTheme.component.elevations.dialog,
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.md, Alignment.End),
+ verticalAlignment = Alignment.CenterVertically,
) {
- Column(
- modifier = Modifier.padding(HarvestCircleTheme.foundation.spacing.xl),
- verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.lg),
- ) {
- Column(
- verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.sm),
+ if (dismissLabel != null && onDismiss != null) {
+ HarvestCircleButton(
+ onClick = onDismiss,
+ modifier =
+ if (focusDismiss) {
+ Modifier.focusRequester(defaultFocusRequester)
+ } else {
+ Modifier
+ },
+ variant = HarvestCircleButtonVariant.Secondary,
+ focusRing = focusRing,
) {
HarvestCircleText(
- text = title,
- role = HarvestCircleTextRole.SectionTitle,
- )
- HarvestCircleText(
- text = message,
- role = HarvestCircleTextRole.Body,
- tone = HarvestCircleContentTone.Secondary,
+ text = dismissLabel,
+ role = HarvestCircleTextRole.Label,
+ tone = HarvestCircleContentTone.Inherit,
)
}
+ }
- Row(
- modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.foundation.spacing.md, Alignment.End),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- if (dismissLabel != null && onDismiss != null) {
- HarvestCircleButton(
- onClick = onDismiss,
- modifier =
- if (focusDismiss) {
- Modifier.focusRequester(defaultFocusRequester)
- } else {
- Modifier
- },
- variant = HarvestCircleButtonVariant.Secondary,
- focusRing = focusRing,
- ) {
- HarvestCircleText(
- text = dismissLabel,
- role = HarvestCircleTextRole.Label,
- tone = HarvestCircleContentTone.Inherit,
- )
- }
- }
-
- HarvestCircleButton(
- onClick = onConfirm,
- modifier =
- if (!focusDismiss) {
- Modifier.focusRequester(defaultFocusRequester)
- } else {
- Modifier
- },
- variant =
- if (destructive) {
- HarvestCircleButtonVariant.Destructive
- } else {
- HarvestCircleButtonVariant.Primary
- },
- focusRing = focusRing,
- ) {
- HarvestCircleText(
- text = confirmLabel,
- role = HarvestCircleTextRole.Label,
- tone = HarvestCircleContentTone.Inherit,
- )
- }
- }
+ HarvestCircleButton(
+ onClick = onConfirm,
+ modifier =
+ if (!focusDismiss) {
+ Modifier.focusRequester(defaultFocusRequester)
+ } else {
+ Modifier
+ },
+ variant =
+ if (destructive) {
+ HarvestCircleButtonVariant.Destructive
+ } else {
+ HarvestCircleButtonVariant.Primary
+ },
+ focusRing = focusRing,
+ ) {
+ HarvestCircleText(
+ text = confirmLabel,
+ role = HarvestCircleTextRole.Label,
+ tone = HarvestCircleContentTone.Inherit,
+ )
}
}
}
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt
@@ -5,7 +5,6 @@ import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@@ -22,15 +21,17 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
-import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
-import androidx.compose.ui.unit.dp
-import androidx.compose.ui.window.Dialog
+import org.harvestcircle.application.BannerSeverity
import org.harvestcircle.application.FoundationOverlay
import org.harvestcircle.application.OverlayIntent
import org.harvestcircle.application.OverlayState
import org.harvestcircle.application.ShellStatusModel
import org.harvestcircle.application.StatusOverlayKey
+import org.harvestcircle.designsystem.component.container.HarvestCircleDialogFrame
+import org.harvestcircle.designsystem.component.feedback.HarvestCircleBanner
+import org.harvestcircle.designsystem.component.feedback.HarvestCircleBannerTone
+import org.harvestcircle.designsystem.theme.HarvestCircleTheme
@Composable
fun FoundationOverlayHost(
@@ -39,19 +40,20 @@ fun FoundationOverlayHost(
onIntent: (OverlayIntent) -> Unit,
) {
status.banner?.let { banner ->
- ShellCard(
- Modifier.semantics { contentDescription = "Status: ${banner.title}. ${banner.message}" }.testTag("global-status-banner"),
- ) {
- Column {
- ShellText(banner.title, textRole = ShellTextRole.CardTitle)
- ShellText(banner.message)
- }
- }
+ HarvestCircleBanner(
+ message = banner.message,
+ modifier =
+ Modifier
+ .semantics { contentDescription = "Status: ${banner.title}. ${banner.message}" }
+ .testTag("global-status-banner"),
+ tone = banner.severity.toBannerTone(),
+ title = banner.title,
+ )
}
state.current?.let { overlay ->
val overlayBusy = (overlay as? FoundationOverlay.ConfirmAction)?.busy == true
val rootRequester = remember { FocusRequester() }
- Dialog(
+ HarvestCircleDialogFrame(
onDismissRequest = {
if (!overlayBusy) {
if (overlay is FoundationOverlay.ConfirmAction) {
@@ -61,27 +63,23 @@ fun FoundationOverlayHost(
}
}
},
- ) {
- ShellSurface(
+ title = overlay.title(),
+ modifier =
Modifier
.focusGroup()
.focusRequester(rootRequester)
.focusable(overlayBusy)
- .semantics {
- contentDescription = "Dialog: ${overlay.title()}"
- paneTitle = overlay.title()
- }.testTag("foundation-overlay")
- .padding(24.dp),
- ) {
- when (overlay) {
- is FoundationOverlay.ConfirmAction -> ConfirmOverlay(overlay, overlayBusy, rootRequester, onIntent)
- is FoundationOverlay.Status ->
- when (overlay.key) {
- StatusOverlayKey.Signer -> StatusOverlay("Signer status", status.signer.text, onIntent)
- StatusOverlayKey.Sync -> StatusOverlay("Sync status", status.sync.text, onIntent)
- }
- is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, onIntent)
- }
+ .semantics { contentDescription = "Dialog: ${overlay.title()}" }
+ .testTag("foundation-overlay"),
+ ) {
+ when (overlay) {
+ is FoundationOverlay.ConfirmAction -> ConfirmOverlay(overlay, overlayBusy, rootRequester, onIntent)
+ is FoundationOverlay.Status ->
+ when (overlay.key) {
+ StatusOverlayKey.Signer -> StatusOverlay("Signer status", status.signer.text, onIntent)
+ StatusOverlayKey.Sync -> StatusOverlay("Sync status", status.sync.text, onIntent)
+ }
+ is FoundationOverlay.OpenNostrReference -> ReferenceOverlay(overlay, onIntent)
}
}
}
@@ -96,10 +94,10 @@ private fun ConfirmOverlay(
) {
val confirmRequester = remember { FocusRequester() }
val cancelRequester = remember { FocusRequester() }
- Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) {
ShellText(overlay.title, Modifier.semantics { heading() }, ShellTextRole.SectionTitle)
ShellText(overlay.explanation)
- Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) {
ShellButton(
overlay.actionLabel,
overlay.actionLabel,
@@ -141,7 +139,7 @@ private fun StatusOverlay(
onIntent: (OverlayIntent) -> Unit,
) {
val requester = remember { FocusRequester() }
- Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) {
ShellText(title, Modifier.semantics { heading() }, ShellTextRole.SectionTitle)
ShellText(status, Modifier.testTag("overlay-status"))
ShellButton(
@@ -168,7 +166,7 @@ private fun ReferenceOverlay(
val inputRequester = remember { FocusRequester() }
val submitRequester = remember { FocusRequester() }
val cancelRequester = remember { FocusRequester() }
- Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) {
ShellText("Open a Nostr reference", Modifier.semantics { heading() }, ShellTextRole.SectionTitle)
ShellTextField(
value = overlay.input,
@@ -185,7 +183,7 @@ private fun ReferenceOverlay(
.testTag("nostr-reference-input"),
)
overlay.result?.let { ShellText(it.message, Modifier.testTag("nostr-reference-result")) }
- Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) {
ShellButton(
"Open a Nostr reference",
"Open a Nostr reference",
@@ -235,3 +233,10 @@ private fun FoundationOverlay.title(): String =
is FoundationOverlay.Status -> if (key == StatusOverlayKey.Signer) "Signer status" else "Sync status"
is FoundationOverlay.OpenNostrReference -> "Open a Nostr reference"
}
+
+private fun BannerSeverity.toBannerTone(): HarvestCircleBannerTone =
+ when (this) {
+ BannerSeverity.Information -> HarvestCircleBannerTone.Info
+ BannerSeverity.Caution -> HarvestCircleBannerTone.Warning
+ BannerSeverity.Critical -> HarvestCircleBannerTone.Error
+ }