commit e47adf2001fce5efd8e90e9ead33b80cf04bd700
parent 6f44b3d3419fb7218dfa8107c68e7c0ee49e1b94
Author: triesap <tyson@radroots.org>
Date: Thu, 13 Aug 2026 17:46:04 +0000
ui: align shell overlays and menus
- add owned Studio-derived dialog banner and menu surfaces
- restyle reference status and confirmation flows
- retain modal focus cycles busy-state denial and typed intents
- keep degraded and critical status visibly distinct
Diffstat:
3 files changed, 279 insertions(+), 47 deletions(-)
diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellOverlays.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/shell/HarvestCircleShellOverlays.kt
@@ -0,0 +1,235 @@
+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.focusable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+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
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+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.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.paneTitle
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntRect
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.LayoutDirection
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.Popup
+import androidx.compose.ui.window.PopupPositionProvider
+import androidx.compose.ui.window.PopupProperties
+import org.harvestcircle.designsystem.component.menu.HarvestCircleMenuOption
+
+public enum class HarvestCircleShellBannerTone { Information, Caution, Critical }
+
+@Composable
+public fun HarvestCircleShellBanner(
+ title: String,
+ message: String,
+ modifier: Modifier = Modifier,
+ tone: HarvestCircleShellBannerTone = HarvestCircleShellBannerTone.Information,
+) {
+ val colors = HarvestCircleShellPalette
+ val border = if (tone == HarvestCircleShellBannerTone.Critical) colors.destructive else colors.border
+ val shape =
+ androidx.compose.foundation.shape
+ .RoundedCornerShape(HarvestCircleShellMetrics.contentPanelRadius)
+ Row(
+ modifier =
+ modifier
+ .fillMaxWidth()
+ .clip(shape)
+ .background(if (tone == HarvestCircleShellBannerTone.Information) colors.accentSubtle else colors.raised)
+ .border(BorderStroke(1.dp, border), shape)
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ HarvestCircleShellText(title, role = HarvestCircleShellTextRole.BodyStrong)
+ HarvestCircleShellText(message, color = colors.contentSecondary)
+ }
+ }
+}
+
+@Composable
+public fun HarvestCircleShellDialogFrame(
+ onDismissRequest: () -> Unit,
+ title: String,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ val colors = HarvestCircleShellPalette
+ val shape =
+ androidx.compose.foundation.shape
+ .RoundedCornerShape(14.dp)
+ Dialog(onDismissRequest = onDismissRequest) {
+ Column(
+ modifier =
+ modifier
+ .widthIn(min = 360.dp, max = 480.dp)
+ .clip(shape)
+ .background(colors.raised)
+ .border(BorderStroke(1.dp, colors.border), shape)
+ .semantics { paneTitle = title }
+ .padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ content = content,
+ )
+ }
+}
+
+@Composable
+public fun <T> HarvestCircleShellMenuButton(
+ selectedValue: T,
+ options: List<HarvestCircleMenuOption<T>>,
+ onValueChange: (T) -> Unit,
+ label: String,
+ modifier: Modifier = Modifier,
+) {
+ require(options.isNotEmpty())
+ var expanded by remember { mutableStateOf(false) }
+ var anchorWidthPx by remember { mutableIntStateOf(0) }
+ var activeIndex by remember(options, selectedValue) {
+ mutableIntStateOf(options.indexOfFirst { it.value == selectedValue }.coerceAtLeast(0))
+ }
+ val density = LocalDensity.current
+ val gapPx = with(density) { 4.dp.roundToPx() }
+ val anchorWidth = with(density) { anchorWidthPx.toDp() }
+ val requester = remember { FocusRequester() }
+ val positionProvider =
+ remember(gapPx) {
+ object : PopupPositionProvider {
+ override fun calculatePosition(
+ anchorBounds: IntRect,
+ windowSize: IntSize,
+ layoutDirection: LayoutDirection,
+ popupContentSize: IntSize,
+ ): IntOffset {
+ val preferredX =
+ if (layoutDirection == LayoutDirection.Ltr) anchorBounds.left else anchorBounds.right - popupContentSize.width
+ val x = preferredX.coerceIn(0, (windowSize.width - popupContentSize.width).coerceAtLeast(0))
+ val below = anchorBounds.bottom + gapPx
+ val y =
+ if (below + popupContentSize.height <= windowSize.height) {
+ below
+ } else {
+ (anchorBounds.top - popupContentSize.height - gapPx).coerceAtLeast(0)
+ }
+ return IntOffset(x, y)
+ }
+ }
+ }
+ Box(modifier.onGloballyPositioned { anchorWidthPx = it.size.width }) {
+ HarvestCircleShellButton(label, { expanded = !expanded })
+ if (expanded) {
+ Popup(
+ popupPositionProvider = positionProvider,
+ onDismissRequest = { expanded = false },
+ properties = PopupProperties(focusable = true),
+ ) {
+ val colors = HarvestCircleShellPalette
+ val shape =
+ androidx.compose.foundation.shape
+ .RoundedCornerShape(HarvestCircleShellMetrics.controlRadius)
+ LaunchedEffect(Unit) { requester.requestFocus() }
+ Column(
+ Modifier
+ .widthIn(min = anchorWidth.coerceAtLeast(176.dp), max = 320.dp)
+ .clip(shape)
+ .background(colors.raised)
+ .border(BorderStroke(1.dp, colors.border), shape)
+ .padding(6.dp)
+ .focusRequester(requester)
+ .focusable()
+ .onPreviewKeyEvent { event ->
+ if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ when (event.key) {
+ Key.DirectionDown -> {
+ activeIndex = nextEnabled(options, activeIndex, 1)
+ true
+ }
+ Key.DirectionUp -> {
+ activeIndex = nextEnabled(options, activeIndex, -1)
+ true
+ }
+ Key.Enter, Key.Spacebar -> {
+ options.getOrNull(activeIndex)?.takeIf { it.enabled }?.let {
+ onValueChange(it.value)
+ expanded = false
+ }
+ true
+ }
+ Key.Escape -> {
+ expanded = false
+ true
+ }
+ else -> false
+ }
+ },
+ ) {
+ options.forEachIndexed { index, option ->
+ val active = index == activeIndex
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(
+ androidx.compose.foundation.shape
+ .RoundedCornerShape(8.dp),
+ ).background(if (active) colors.navigationSelected else colors.raised)
+ .clickable(enabled = option.enabled, role = Role.Button) {
+ onValueChange(option.value)
+ expanded = false
+ }.padding(horizontal = 10.dp, vertical = 8.dp),
+ ) {
+ HarvestCircleShellText(
+ option.label,
+ color = if (option.enabled) colors.contentPrimary else colors.contentDisabled,
+ maxLines = 1,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun <T> nextEnabled(
+ options: List<HarvestCircleMenuOption<T>>,
+ current: Int,
+ direction: Int,
+): Int {
+ var candidate = current
+ repeat(options.size) {
+ candidate = (candidate + direction + options.size).mod(options.size)
+ if (options[candidate].enabled) return candidate
+ }
+ return current
+}
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,6 +5,7 @@ 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,21 +23,20 @@ import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
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.HarvestCircleButtonVariant
-import org.harvestcircle.designsystem.component.HarvestCircleTextRole
-import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton
-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.component.input.HarvestCircleTextField
-import org.harvestcircle.designsystem.primitive.HarvestCircleText
-import org.harvestcircle.designsystem.theme.HarvestCircleTheme
+import org.harvestcircle.designsystem.shell.HarvestCircleShellBanner
+import org.harvestcircle.designsystem.shell.HarvestCircleShellBannerTone
+import org.harvestcircle.designsystem.shell.HarvestCircleShellButton
+import org.harvestcircle.designsystem.shell.HarvestCircleShellDialogFrame
+import org.harvestcircle.designsystem.shell.HarvestCircleShellText
+import org.harvestcircle.designsystem.shell.HarvestCircleShellTextField
+import org.harvestcircle.designsystem.shell.HarvestCircleShellTextRole
@Composable
fun FoundationOverlayHost(
@@ -45,20 +45,21 @@ fun FoundationOverlayHost(
onIntent: (OverlayIntent) -> Unit,
) {
status.banner?.let { banner ->
- HarvestCircleBanner(
+ HarvestCircleShellBanner(
message = banner.message,
modifier =
Modifier
+ .padding(16.dp)
.semantics { contentDescription = "Status: ${banner.title}. ${banner.message}" }
.testTag("global-status-banner"),
- tone = banner.severity.toBannerTone(),
+ tone = banner.severity.toShellBannerTone(),
title = banner.title,
)
}
state.current?.let { overlay ->
val overlayBusy = (overlay as? FoundationOverlay.ConfirmAction)?.busy == true
val rootRequester = remember { FocusRequester() }
- HarvestCircleDialogFrame(
+ HarvestCircleShellDialogFrame(
onDismissRequest = {
if (!overlayBusy) {
if (overlay is FoundationOverlay.ConfirmAction) {
@@ -99,12 +100,11 @@ private fun ConfirmOverlay(
) {
val confirmRequester = remember { FocusRequester() }
val cancelRequester = remember { FocusRequester() }
- Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) {
- HarvestCircleText(overlay.title, Modifier.semantics { heading() }, HarvestCircleTextRole.SectionTitle)
- HarvestCircleText(overlay.explanation)
- Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) {
- HarvestCircleLabeledButton(
- overlay.actionLabel,
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ HarvestCircleShellText(overlay.title, Modifier.semantics { heading() }, HarvestCircleShellTextRole.SectionTitle)
+ HarvestCircleShellText(overlay.explanation)
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ HarvestCircleShellButton(
overlay.actionLabel,
{ onIntent(OverlayIntent.Confirm(overlay.action)) },
Modifier
@@ -115,10 +115,9 @@ private fun ConfirmOverlay(
}.modalFocusCycle(cancelRequester, cancelRequester)
.testTag("overlay-confirm"),
enabled = !busy,
- variant = HarvestCircleButtonVariant.Destructive,
+ destructive = true,
)
- HarvestCircleLabeledButton(
- "Cancel",
+ HarvestCircleShellButton(
"Cancel",
{ onIntent(OverlayIntent.DismissConfirmation(overlay.action)) },
Modifier
@@ -128,7 +127,7 @@ private fun ConfirmOverlay(
previous = confirmRequester
}.modalFocusCycle(confirmRequester, confirmRequester)
.testTag("overlay-cancel"),
- !busy,
+ enabled = !busy,
)
}
}
@@ -144,11 +143,10 @@ private fun StatusOverlay(
onIntent: (OverlayIntent) -> Unit,
) {
val requester = remember { FocusRequester() }
- Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) {
- HarvestCircleText(title, Modifier.semantics { heading() }, HarvestCircleTextRole.SectionTitle)
- HarvestCircleText(status, Modifier.testTag("overlay-status"))
- HarvestCircleLabeledButton(
- "Close",
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ HarvestCircleShellText(title, Modifier.semantics { heading() }, HarvestCircleShellTextRole.SectionTitle)
+ HarvestCircleShellText(status, Modifier.testTag("overlay-status"))
+ HarvestCircleShellButton(
"Close",
{ onIntent(OverlayIntent.Close) },
Modifier
@@ -171,9 +169,13 @@ private fun ReferenceOverlay(
val inputRequester = remember { FocusRequester() }
val submitRequester = remember { FocusRequester() }
val cancelRequester = remember { FocusRequester() }
- Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) {
- HarvestCircleText("Open a Nostr reference", Modifier.semantics { heading() }, HarvestCircleTextRole.SectionTitle)
- HarvestCircleTextField(
+ Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ HarvestCircleShellText(
+ "Open a Nostr reference",
+ Modifier.semantics { heading() },
+ HarvestCircleShellTextRole.SectionTitle,
+ )
+ HarvestCircleShellTextField(
value = overlay.input,
onValueChange = { onIntent(OverlayIntent.EditReference(it)) },
label = "Nostr link, note1, nevent1, or address",
@@ -187,10 +189,9 @@ private fun ReferenceOverlay(
}.modalFocusCycle(submitRequester, cancelRequester)
.testTag("nostr-reference-input"),
)
- overlay.result?.let { HarvestCircleText(it.message, Modifier.testTag("nostr-reference-result")) }
- Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) {
- HarvestCircleLabeledButton(
- "Open a Nostr reference",
+ overlay.result?.let { HarvestCircleShellText(it.message, Modifier.testTag("nostr-reference-result")) }
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ HarvestCircleShellButton(
"Open a Nostr reference",
{ onIntent(OverlayIntent.SubmitReference) },
Modifier
@@ -200,10 +201,9 @@ private fun ReferenceOverlay(
previous = inputRequester
}.modalFocusCycle(cancelRequester, inputRequester)
.testTag("nostr-reference-submit"),
- variant = HarvestCircleButtonVariant.Primary,
+ primary = true,
)
- HarvestCircleLabeledButton(
- "Cancel",
+ HarvestCircleShellButton(
"Cancel",
{ onIntent(OverlayIntent.Close) },
Modifier
@@ -239,9 +239,9 @@ private fun FoundationOverlay.title(): String =
is FoundationOverlay.OpenNostrReference -> "Open a Nostr reference"
}
-private fun BannerSeverity.toBannerTone(): HarvestCircleBannerTone =
+private fun BannerSeverity.toShellBannerTone(): HarvestCircleShellBannerTone =
when (this) {
- BannerSeverity.Information -> HarvestCircleBannerTone.Info
- BannerSeverity.Caution -> HarvestCircleBannerTone.Warning
- BannerSeverity.Critical -> HarvestCircleBannerTone.Error
+ BannerSeverity.Information -> HarvestCircleShellBannerTone.Information
+ BannerSeverity.Caution -> HarvestCircleShellBannerTone.Caution
+ BannerSeverity.Critical -> HarvestCircleShellBannerTone.Critical
}
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt
@@ -14,13 +14,12 @@ import androidx.compose.ui.platform.testTag
import org.harvestcircle.application.ShellFocusTarget
import org.harvestcircle.application.SignerStatusLabel
import org.harvestcircle.application.SyncStatusLabel
-import org.harvestcircle.designsystem.component.HarvestCircleControlSize
import org.harvestcircle.designsystem.component.menu.HarvestCircleMenuOption
-import org.harvestcircle.designsystem.component.menu.HarvestCirclePopupButton
import org.harvestcircle.designsystem.icon.HarvestCircleIcons
import org.harvestcircle.designsystem.primitive.HarvestCircleIcon
import org.harvestcircle.designsystem.shell.HarvestCircleShellButton
import org.harvestcircle.designsystem.shell.HarvestCircleShellIconButton
+import org.harvestcircle.designsystem.shell.HarvestCircleShellMenuButton
import org.harvestcircle.designsystem.shell.HarvestCircleShellMetrics
import org.harvestcircle.designsystem.shell.HarvestCircleShellPalette
import org.harvestcircle.designsystem.shell.HarvestCircleShellTab
@@ -144,14 +143,12 @@ fun GlobalTopBar(
{ onIntent(GlobalTopBarIntent.ShowSignerStatus) },
Modifier.shellFocusTarget(ShellFocusTarget.TopBarSigner).testTag("top-bar-signer"),
)
- HarvestCirclePopupButton(
+ HarvestCircleShellMenuButton(
selectedValue = ApplicationMenuAction.Settings,
options = ApplicationMenuAction.entries.map { HarvestCircleMenuOption(it, it.label) },
onValueChange = { onIntent(GlobalTopBarIntent.SelectApplicationMenu(it)) },
modifier = Modifier.testTag("top-bar-menu"),
- buttonLabel = "Menu",
- showSelection = false,
- size = HarvestCircleControlSize.Small,
+ label = "Menu",
)
}
}