app

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

commit 36e08b79fbfb53d106532e78f3cb0bf781b8ed02
parent 5c867de20e9f49a228861e8e4bdd53b943f7e2cb
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 16:19:48 +0000

ui: remove superseded shell adapters

- consume owned design-system text, fields, cards, tabs, badges, and buttons directly
- add canonical labeled-button and compact badge component APIs
- remove the generic shell control bridge and its audit exemption
- delete unused master-detail and legacy-named workbench templates

Diffstat:
Mapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/action/HarvestCircleButton.kt | 31+++++++++++++++++++++++++++++++
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/feedback/HarvestCircleBadge.kt | 42++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapIdentityEntry.kt | 50++++++++++++++++++++++++++++++++------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/BootstrapRecoveryAndChooser.kt | 110++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt | 49+++++++++++++++++++++++++++++++++----------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt | 35++++++++++++++++++++---------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt | 33+++++++++++++++++++++++----------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt | 38+++++++++++++++++++++++++-------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt | 12------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 83+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/LifecycleCanvases.kt | 31++++++++++++++++++++-----------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelHeader.kt | 3++-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelTemplates.kt | 39---------------------------------------
Dapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt | 177-------------------------------------------------------------------------------
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/LifecycleCanvasesTest.kt | 2+-
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/MainPanelTemplatesTest.kt | 20++------------------
Aapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/OwnedControlsUiTest.kt | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt | 30++++++++++++++++++++----------
Dapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt | 85-------------------------------------------------------------------------------
Mtools/xtask/src/lib.rs | 12++----------
20 files changed, 484 insertions(+), 513 deletions(-)

diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/action/HarvestCircleButton.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/action/HarvestCircleButton.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.graphics.Brush 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.semantics import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.Dp @@ -26,6 +27,7 @@ import androidx.compose.ui.unit.dp import org.harvestcircle.designsystem.component.HarvestCircleButtonVariant import org.harvestcircle.designsystem.component.HarvestCircleControlSize import org.harvestcircle.designsystem.component.HarvestCircleFocusRing +import org.harvestcircle.designsystem.component.HarvestCircleTextRole import org.harvestcircle.designsystem.generated.resources.Res import org.harvestcircle.designsystem.generated.resources.harvestcircle_loading import org.harvestcircle.designsystem.internal.chrome.HarvestCircleMacBezel @@ -36,6 +38,7 @@ import org.harvestcircle.designsystem.internal.interaction.harvestCircleHoverabl import org.harvestcircle.designsystem.internal.interaction.harvestCircleInteractions import org.harvestcircle.designsystem.internal.interaction.rememberHarvestCircleInteractionSources import org.harvestcircle.designsystem.internal.progress.HarvestCircleMacSpinner +import org.harvestcircle.designsystem.primitive.HarvestCircleText import org.harvestcircle.designsystem.primitive.ProvideHarvestCircleContentColor import org.harvestcircle.designsystem.theme.HarvestCircleTheme import org.harvestcircle.designsystem.theme.color.HarvestCircleActionStateColors @@ -260,3 +263,31 @@ public fun HarvestCircleButton( } } } + +/** Canonical text-labeled button with an explicit accessibility label. */ +@Composable +public fun HarvestCircleLabeledButton( + label: String, + accessibilityLabel: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + variant: HarvestCircleButtonVariant = HarvestCircleButtonVariant.Secondary, + size: HarvestCircleControlSize = HarvestCircleControlSize.Medium, + loading: Boolean = false, + leadingIcon: (@Composable () -> Unit)? = null, + focusRing: HarvestCircleFocusRing = HarvestCircleFocusRing.WhenFocused, +) { + HarvestCircleButton( + onClick = onClick, + modifier = modifier.semantics { contentDescription = accessibilityLabel }, + variant = variant, + size = size, + enabled = enabled, + loading = loading, + leadingIcon = leadingIcon, + focusRing = focusRing, + ) { + HarvestCircleText(text = label, role = HarvestCircleTextRole.Label) + } +} diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/feedback/HarvestCircleBadge.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/feedback/HarvestCircleBadge.kt @@ -0,0 +1,42 @@ +package org.harvestcircle.designsystem.component.feedback + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.semantics +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.primitive.HarvestCircleSurface +import org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole +import org.harvestcircle.designsystem.primitive.HarvestCircleText +import org.harvestcircle.designsystem.theme.HarvestCircleTheme + +/** Compact, non-interactive status label. */ +@Composable +public fun HarvestCircleBadge( + label: String, + modifier: Modifier = Modifier, +) { + HarvestCircleSurface( + modifier = modifier.semantics(mergeDescendants = true) {}, + role = HarvestCircleSurfaceRole.Base, + shape = HarvestCircleTheme.foundation.shapes.control, + border = + BorderStroke( + HarvestCircleTheme.shell.dimensions.dividerWidth, + HarvestCircleTheme.foundation.colors.border.default, + ), + ) { + HarvestCircleText( + text = label, + modifier = + Modifier.padding( + horizontal = HarvestCircleTheme.foundation.spacing.md, + vertical = HarvestCircleTheme.foundation.spacing.xs, + ), + role = HarvestCircleTextRole.LabelSmall, + tone = HarvestCircleContentTone.Secondary, + ) + } +} 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 @@ -15,6 +15,10 @@ import androidx.compose.ui.semantics.password import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.input.PasswordVisualTransformation import org.harvestcircle.appearance.TextSizePreference +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton +import org.harvestcircle.designsystem.component.input.HarvestCircleTextField +import org.harvestcircle.designsystem.primitive.HarvestCircleText import org.harvestcircle.designsystem.theme.HarvestCircleTheme import org.harvestcircle.identities.ui.HarvestCircleUiActions import org.harvestcircle.identities.ui.HarvestCircleUiModel @@ -30,15 +34,17 @@ fun BootstrapIdentityEntry( require(step == BootstrapStep.CreateIdentity || step == BootstrapStep.ImportIdentity) CanvasScaffold( textSize = TextSizePreference.Default, - navigation = { ShellAction("Back", "Back", "identity-entry-back", onClick = onBack) }, + navigation = { + HarvestCircleLabeledButton("Back", "Back", onBack, Modifier.testTag("identity-entry-back")) + }, header = { - ShellText( + HarvestCircleText( if (step == BootstrapStep.CreateIdentity) { "Create a local Nostr identity" } else { "Import an existing identity" }, - textRole = ShellTextRole.ScreenTitle, + role = HarvestCircleTextRole.PageTitle, ) }, body = { @@ -51,15 +57,23 @@ fun BootstrapIdentityEntry( actionBar = { Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { if (step == BootstrapStep.CreateIdentity) { - ShellAction("Generate identity", "Generate identity", "generate-key", enabled = !model.busy) { - actions.generateIdentity() - } + HarvestCircleLabeledButton( + "Generate identity", + "Generate identity", + { actions.generateIdentity() }, + Modifier.testTag("generate-key"), + enabled = !model.busy, + ) } else { - ShellAction("Import identity", "Import identity", "import-key", enabled = !model.busy) { - actions.importSecretKey() - } + HarvestCircleLabeledButton( + "Import identity", + "Import identity", + { actions.importSecretKey() }, + Modifier.testTag("import-key"), + enabled = !model.busy, + ) } - ShellAction("Back", "Back", "identity-entry-cancel", onClick = onBack) + HarvestCircleLabeledButton("Back", "Back", onBack, Modifier.testTag("identity-entry-cancel")) } }, ) @@ -71,9 +85,9 @@ private fun CreateIdentityBody(model: HarvestCircleUiModel) { 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")) } + HarvestCircleText("HarvestCircle will generate a new Nostr identity.") + HarvestCircleText("Save the recovery key before the identity is stored in the operating-system keyring.") + model.problem?.let { HarvestCircleText(it, Modifier.testTag("identity-entry-problem")) } } } @@ -87,12 +101,12 @@ private fun ImportIdentityBody( Modifier.testTag("import-identity-entry"), verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap), ) { - ShellTextField( + HarvestCircleTextField( value = model.importDraft.revealForDisplay(), onValueChange = actions.editImportDraft, label = "Nostr secret key", placeholder = "nsec1…", - modifier = + inputModifier = Modifier .focusRequester(requester) .semantics { @@ -101,12 +115,12 @@ private fun ImportIdentityBody( }.testTag("import-nsec-input"), visualTransformation = PasswordVisualTransformation(), ) - ShellText( + HarvestCircleText( "The secret is held only for this import.\n\n" + "It is cleared after it is sent to the local native runtime.", ) - model.importGuidance?.let { ShellText(it, Modifier.testTag("identity-entry-guidance")) } - model.problem?.let { ShellText(it, Modifier.testTag("identity-entry-problem")) } + model.importGuidance?.let { HarvestCircleText(it, Modifier.testTag("identity-entry-guidance")) } + model.problem?.let { HarvestCircleText(it, Modifier.testTag("identity-entry-problem")) } } LaunchedEffect(Unit) { requester.requestFocus() } } 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 @@ -13,6 +13,10 @@ import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import org.harvestcircle.appearance.TextSizePreference import org.harvestcircle.application.ShellFocusTarget +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton +import org.harvestcircle.designsystem.component.feedback.HarvestCircleBadge +import org.harvestcircle.designsystem.primitive.HarvestCircleText import org.harvestcircle.designsystem.theme.HarvestCircleTheme import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleUiActions @@ -28,29 +32,38 @@ fun GeneratedRecoveryCanvas( val backup = model.generatedKeyBackup ?: return CanvasScaffold( textSize = TextSizePreference.Default, - header = { ShellText("Save your recovery key", textRole = ShellTextRole.ScreenTitle) }, + header = { HarvestCircleText("Save your recovery key", role = HarvestCircleTextRole.PageTitle) }, body = { 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) - ShellText(backup.nsec, Modifier.testTag("generated-nsec"), ShellTextRole.Protocol) + HarvestCircleText("This key is shown once.") + HarvestCircleText("Store it somewhere private before continuing.") + HarvestCircleText("Recovery key", role = HarvestCircleTextRole.SubsectionTitle) + HarvestCircleText(backup.nsec, Modifier.testTag("generated-nsec"), HarvestCircleTextRole.Code) } }, actionBar = { Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { - ShellAction("Copy recovery key", "Copy recovery key", "copy-generated-key") { - platformActions.copySecret(backup.nsec) - } - ShellAction("I have saved the recovery key", "I have saved the recovery key", "acknowledge-key-backup") { - actions.acknowledgeGeneratedKeyBackup() - } - ShellAction("Cancel identity creation", "Cancel identity creation", "cancel-generated-key") { - actions.cancelGeneratedKeyBackup() - } + HarvestCircleLabeledButton( + "Copy recovery key", + "Copy recovery key", + { platformActions.copySecret(backup.nsec) }, + Modifier.testTag("copy-generated-key"), + ) + HarvestCircleLabeledButton( + "I have saved the recovery key", + "I have saved the recovery key", + { actions.acknowledgeGeneratedKeyBackup() }, + Modifier.testTag("acknowledge-key-backup"), + ) + HarvestCircleLabeledButton( + "Cancel identity creation", + "Cancel identity creation", + { actions.cancelGeneratedKeyBackup() }, + Modifier.testTag("cancel-generated-key"), + ) } }, ) @@ -64,7 +77,7 @@ fun IdentityChooserCanvas( ) { CanvasScaffold( textSize = TextSizePreference.Default, - header = { ShellText("Choose a Nostr identity", textRole = ShellTextRole.ScreenTitle) }, + header = { HarvestCircleText("Choose a Nostr identity", role = HarvestCircleTextRole.PageTitle) }, body = { LazyColumn(Modifier.fillMaxWidth().testTag("saved-identity-list")) { items(model.identities, key = IdentityUiModel::publicKeyHex) { identity -> @@ -74,13 +87,19 @@ fun IdentityChooserCanvas( }, actionBar = { Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { - ShellAction("Create another identity", "Create another identity", "choose-create-identity") { - actions.chooseCreateIdentity() - } - ShellAction("Import identity", "Import identity", "choose-import-identity") { - actions.chooseImportIdentity() - } - ShellAction("Explore read-only", "Explore read-only", "chooser-read-only", onClick = onReadOnly) + HarvestCircleLabeledButton( + "Create another identity", + "Create another identity", + { actions.chooseCreateIdentity() }, + Modifier.testTag("choose-create-identity"), + ) + HarvestCircleLabeledButton( + "Import identity", + "Import identity", + { actions.chooseImportIdentity() }, + Modifier.testTag("choose-import-identity"), + ) + HarvestCircleLabeledButton("Explore read-only", "Explore read-only", onReadOnly, Modifier.testTag("chooser-read-only")) } }, ) @@ -99,40 +118,39 @@ private fun IdentityRow( .testTag("identity-row:${identity.publicKeyHex}"), verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap), ) { - ShellText(identity.label, textRole = ShellTextRole.CardTitle) - ShellText(identity.shortNpub, textRole = ShellTextRole.Protocol) - ShellText( + HarvestCircleText(identity.label, role = HarvestCircleTextRole.SubsectionTitle) + HarvestCircleText(identity.shortNpub, role = HarvestCircleTextRole.Code) + HarvestCircleText( if (identity.signerAvailability == org.harvestcircle.application.SignerAvailability.Available) { "Local credential available" } else { "Local credential unavailable" }, ) - if (identity.selected) ShellBadge("Selected") - ShellAction( + if (identity.selected) HarvestCircleBadge("Selected") + HarvestCircleLabeledButton( label = if (identity.selected) "Selected identity" else "Select identity", - description = "Select ${identity.label}", - tag = "select-identity:${identity.publicKeyHex}", + accessibilityLabel = "Select ${identity.label}", + modifier = Modifier.testTag("select-identity:${identity.publicKeyHex}"), enabled = !model.busy && !identity.selected, - ) { - actions.selectIdentity(identity.publicKeyHex) - } - ShellAction( + onClick = { actions.selectIdentity(identity.publicKeyHex) }, + ) + HarvestCircleLabeledButton( label = if (identity.active) "Active identity" else "Activate identity", - description = "Activate ${identity.label}", - tag = "activate-identity:${identity.publicKeyHex}", + accessibilityLabel = "Activate ${identity.label}", + modifier = Modifier.testTag("activate-identity:${identity.publicKeyHex}"), enabled = !model.busy && !identity.active, - ) { - actions.activateIdentity(identity.publicKeyHex) - } - ShellAction( - "Remove local identity", - "Remove ${identity.label}", - "remove-identity:${identity.publicKeyHex}", + onClick = { actions.activateIdentity(identity.publicKeyHex) }, + ) + HarvestCircleLabeledButton( + label = "Remove local identity", + accessibilityLabel = "Remove ${identity.label}", enabled = !model.busy, - modifier = Modifier.shellFocusTarget(ShellFocusTarget.IdentityRow(identity.publicKeyHex)), - ) { - actions.requestIdentityRemoval(identity.publicKeyHex) - } + modifier = + Modifier + .shellFocusTarget(ShellFocusTarget.IdentityRow(identity.publicKeyHex)) + .testTag("remove-identity:${identity.publicKeyHex}"), + onClick = { actions.requestIdentityRemoval(identity.publicKeyHex) }, + ) } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationNetworkScreen.kt @@ -14,8 +14,14 @@ import org.harvestcircle.application.HarvestCircleShellState import org.harvestcircle.application.RelayConnectionState import org.harvestcircle.application.RelayDestination import org.harvestcircle.application.SignerAvailability +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton +import org.harvestcircle.designsystem.component.container.HarvestCircleCard +import org.harvestcircle.designsystem.component.feedback.HarvestCircleBadge import org.harvestcircle.designsystem.component.navigation.HarvestCircleTab import org.harvestcircle.designsystem.component.navigation.HarvestCircleTabRow +import org.harvestcircle.designsystem.primitive.HarvestCircleText import org.harvestcircle.designsystem.theme.HarvestCircleTheme enum class NetworkIdentityState { ReadOnly, Active, CredentialUnavailable, Available, SignedOut } @@ -132,36 +138,43 @@ private fun NetworkDetail( model.relays.count { it.writeCapability == RelayCapability.Configured }.toString(), ) Fact("Local runtime", model.runtimeState.label()) - ShellText("No managed HarvestCircle service is configured.") + HarvestCircleText("No managed HarvestCircle service is configured.") } "identity" -> { - ShellBadge(model.identityState.label(), Modifier.testTag("network-identity-state")) - model.identityLabel?.let { ShellText(it, Modifier.testTag("network-identity-label"), ShellTextRole.CardTitle) } - model.profileLabel?.let { ShellText("Display name: $it", Modifier.testTag("network-profile-label")) } + HarvestCircleBadge(model.identityState.label(), Modifier.testTag("network-identity-state")) + model.identityLabel?.let { + HarvestCircleText(it, Modifier.testTag("network-identity-label"), HarvestCircleTextRole.SubsectionTitle) + } + model.profileLabel?.let { HarvestCircleText("Display name: $it", Modifier.testTag("network-profile-label")) } if (model.identityState == NetworkIdentityState.Active) { - ShellAction("Refresh profile", "Refresh active profile", "refresh-profile", onClick = refreshProfile) - ShellAction("Sign out", "Sign out", "sign-out", onClick = signOut) + HarvestCircleLabeledButton( + "Refresh profile", + "Refresh active profile", + refreshProfile, + Modifier.testTag("refresh-profile"), + ) + HarvestCircleLabeledButton("Sign out", "Sign out", signOut, Modifier.testTag("sign-out")) } } "public_relays" -> { - ShellBadge(model.relayState.label(), Modifier.testTag("network-relay-state")) + HarvestCircleBadge(model.relayState.label(), Modifier.testTag("network-relay-state")) if (model.relays.isEmpty()) { - ShellText("No public relay endpoints are configured.", Modifier.testTag("network-relays-empty")) + HarvestCircleText("No public relay endpoints are configured.", Modifier.testTag("network-relays-empty")) } model.relays.forEach { relay -> - ShellCard(Modifier.testTag("network-relay:${relay.url}")) { + HarvestCircleCard(Modifier.testTag("network-relay:${relay.url}")) { Column { - ShellText(relay.url, textRole = ShellTextRole.Protocol) - ShellText(relay.destination.label()) - ShellText(relay.readCapability.label("Read")) - ShellText(relay.writeCapability.label("Write")) + HarvestCircleText(relay.url, role = HarvestCircleTextRole.Code) + HarvestCircleText(relay.destination.label()) + HarvestCircleText(relay.readCapability.label("Read")) + HarvestCircleText(relay.writeCapability.label("Write")) } } } } "runtime" -> { Fact("Local runtime", model.runtimeState.label()) - model.runtimeProblem?.let { ShellText(it, Modifier.testTag("network-runtime-problem")) } + model.runtimeProblem?.let { HarvestCircleText(it, Modifier.testTag("network-runtime-problem")) } } } } @@ -173,8 +186,12 @@ private fun Fact( value: String, ) { Column { - ShellText(label, textRole = ShellTextRole.Secondary) - ShellText(value, Modifier.testTag("network-fact-${label.lowercase().replace(' ', '-') }")) + HarvestCircleText( + label, + role = HarvestCircleTextRole.BodySmall, + tone = HarvestCircleContentTone.Secondary, + ) + HarvestCircleText(value, Modifier.testTag("network-fact-${label.lowercase().replace(' ', '-') }")) } } 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 @@ -28,9 +28,14 @@ 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 @Composable @@ -95,10 +100,10 @@ private fun ConfirmOverlay( val confirmRequester = remember { FocusRequester() } val cancelRequester = remember { FocusRequester() } Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) { - ShellText(overlay.title, Modifier.semantics { heading() }, ShellTextRole.SectionTitle) - ShellText(overlay.explanation) + HarvestCircleText(overlay.title, Modifier.semantics { heading() }, HarvestCircleTextRole.SectionTitle) + HarvestCircleText(overlay.explanation) Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { - ShellButton( + HarvestCircleLabeledButton( overlay.actionLabel, overlay.actionLabel, { onIntent(OverlayIntent.Confirm(overlay.action)) }, @@ -110,9 +115,9 @@ private fun ConfirmOverlay( }.modalFocusCycle(cancelRequester, cancelRequester) .testTag("overlay-confirm"), enabled = !busy, - kind = ShellButtonKind.Destructive, + variant = HarvestCircleButtonVariant.Destructive, ) - ShellButton( + HarvestCircleLabeledButton( "Cancel", "Cancel", { onIntent(OverlayIntent.DismissConfirmation(overlay.action)) }, @@ -140,9 +145,9 @@ private fun StatusOverlay( ) { val requester = remember { FocusRequester() } Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) { - ShellText(title, Modifier.semantics { heading() }, ShellTextRole.SectionTitle) - ShellText(status, Modifier.testTag("overlay-status")) - ShellButton( + HarvestCircleText(title, Modifier.semantics { heading() }, HarvestCircleTextRole.SectionTitle) + HarvestCircleText(status, Modifier.testTag("overlay-status")) + HarvestCircleLabeledButton( "Close", "Close", { onIntent(OverlayIntent.Close) }, @@ -167,13 +172,13 @@ private fun ReferenceOverlay( val submitRequester = remember { FocusRequester() } val cancelRequester = remember { FocusRequester() } Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) { - ShellText("Open a Nostr reference", Modifier.semantics { heading() }, ShellTextRole.SectionTitle) - ShellTextField( + HarvestCircleText("Open a Nostr reference", Modifier.semantics { heading() }, HarvestCircleTextRole.SectionTitle) + HarvestCircleTextField( value = overlay.input, onValueChange = { onIntent(OverlayIntent.EditReference(it)) }, label = "Nostr link, note1, nevent1, or address", placeholder = "nostr:…", - modifier = + inputModifier = Modifier .focusRequester(inputRequester) .focusProperties { @@ -182,9 +187,9 @@ private fun ReferenceOverlay( }.modalFocusCycle(submitRequester, cancelRequester) .testTag("nostr-reference-input"), ) - overlay.result?.let { ShellText(it.message, Modifier.testTag("nostr-reference-result")) } + overlay.result?.let { HarvestCircleText(it.message, Modifier.testTag("nostr-reference-result")) } Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { - ShellButton( + HarvestCircleLabeledButton( "Open a Nostr reference", "Open a Nostr reference", { onIntent(OverlayIntent.SubmitReference) }, @@ -195,9 +200,9 @@ private fun ReferenceOverlay( previous = inputRequester }.modalFocusCycle(cancelRequester, inputRequester) .testTag("nostr-reference-submit"), - kind = ShellButtonKind.Primary, + variant = HarvestCircleButtonVariant.Primary, ) - ShellButton( + HarvestCircleLabeledButton( "Cancel", "Cancel", { onIntent(OverlayIntent.Close) }, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt @@ -12,8 +12,12 @@ import org.harvestcircle.appearance.TextSizePreference import org.harvestcircle.appearance.ThemePreference import org.harvestcircle.application.BuildDirtyState import org.harvestcircle.application.BuildInfo +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton import org.harvestcircle.designsystem.component.navigation.HarvestCircleTab import org.harvestcircle.designsystem.component.navigation.HarvestCircleTabRow +import org.harvestcircle.designsystem.primitive.HarvestCircleText import org.harvestcircle.designsystem.theme.HarvestCircleTheme import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.navigation.SettingsSection @@ -84,21 +88,21 @@ private fun AppearanceSettings( Modifier.testTag("settings-appearance-panel"), verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.sectionGap), ) { - ShellText("Theme", textRole = ShellTextRole.SectionTitle) + HarvestCircleText("Theme", role = HarvestCircleTextRole.SectionTitle) OptionRow( listOf(ThemePreference.System, ThemePreference.Light, ThemePreference.Dark), appearance.theme, "theme", actions.setTheme, ) - ShellText("Text size", textRole = ShellTextRole.SectionTitle) + HarvestCircleText("Text size", role = HarvestCircleTextRole.SectionTitle) OptionRow( listOf(TextSizePreference.Default, TextSizePreference.Large, TextSizePreference.VeryLarge), appearance.textSize, "text-size", actions.setTextSize, ) - ShellText("Motion", textRole = ShellTextRole.SectionTitle) + HarvestCircleText("Motion", role = HarvestCircleTextRole.SectionTitle) OptionRow( listOf(MotionPreference.Standard, MotionPreference.Reduced), appearance.motion, @@ -153,11 +157,16 @@ private fun ProjectSettings( ) ProjectFact("Licence", "GPL-3.0-only") Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { - ShellAction("Source", "Open HarvestCircle source", "project-open-source", onClick = platformActions.openSource) - ShellAction( - "Licence", - "Open HarvestCircle licence", - "project-open-licence", + HarvestCircleLabeledButton( + "Source", + "Open HarvestCircle source", + platformActions.openSource, + Modifier.testTag("project-open-source"), + ) + HarvestCircleLabeledButton( + label = "Licence", + accessibilityLabel = "Open HarvestCircle licence", + modifier = Modifier.testTag("project-open-licence"), onClick = platformActions.openLicence, ) } @@ -170,8 +179,12 @@ private fun ProjectFact( value: String, ) { Column { - ShellText(label, textRole = ShellTextRole.Secondary) - ShellText(value, Modifier.testTag("project-${label.lowercase().replace(' ', '-')}")) + HarvestCircleText( + label, + role = HarvestCircleTextRole.BodySmall, + tone = HarvestCircleContentTone.Secondary, + ) + HarvestCircleText(value, Modifier.testTag("project-${label.lowercase().replace(' ', '-')}")) } } diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationTodayScreen.kt @@ -7,6 +7,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import org.harvestcircle.application.ShellFocusTarget +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton +import org.harvestcircle.designsystem.component.feedback.HarvestCircleBadge +import org.harvestcircle.designsystem.primitive.HarvestCircleText import org.harvestcircle.designsystem.theme.HarvestCircleTheme data class FoundationTodayModel( @@ -23,26 +28,33 @@ fun FoundationTodayScreen( Modifier.testTag("foundation-today"), verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap), ) { - ShellBadge(model.context, Modifier.testTag("today-context")) - ShellText("No active commitments", textRole = ShellTextRole.SectionTitle) - ShellText("Explore nearby buying circles or open a shared Nostr reference.") + HarvestCircleBadge(model.context, Modifier.testTag("today-context")) + HarvestCircleText("No active commitments", role = HarvestCircleTextRole.SectionTitle) + HarvestCircleText("Explore nearby buying circles or open a shared Nostr reference.") Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { - ShellAction( - "Explore circles", - "Explore circles", - "today-explore-circles", + HarvestCircleLabeledButton( + label = "Explore circles", + accessibilityLabel = "Explore circles", + modifier = Modifier.testTag("today-explore-circles"), enabled = false, onClick = {}, ) - ShellAction( - "Open a Nostr reference", - "Open a Nostr reference", - "today-open-reference", - modifier = Modifier.shellFocusTarget(ShellFocusTarget.TodayReference), + HarvestCircleLabeledButton( + label = "Open a Nostr reference", + accessibilityLabel = "Open a Nostr reference", + modifier = + Modifier + .shellFocusTarget(ShellFocusTarget.TodayReference) + .testTag("today-open-reference"), onClick = openNostrReference, ) } - ShellText("Not available in this build.", Modifier.testTag("today-deferred-helper"), ShellTextRole.Secondary) + HarvestCircleText( + "Not available in this build.", + Modifier.testTag("today-deferred-helper"), + HarvestCircleTextRole.BodySmall, + HarvestCircleContentTone.Secondary, + ) } } } 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 @@ -134,15 +134,3 @@ private fun StatusAction( HarvestCircleText(label, role = HarvestCircleTextRole.Label) } } - -@Composable -internal fun ShellAction( - label: String, - description: String, - tag: String, - enabled: Boolean = true, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - ShellButton(label, description, onClick, modifier.testTag(tag), enabled) -} 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 @@ -22,6 +22,10 @@ import org.harvestcircle.application.ShellFocusTarget import org.harvestcircle.application.ShellRoot import org.harvestcircle.application.StatusOverlayKey import org.harvestcircle.application.deriveShellStatus +import org.harvestcircle.designsystem.component.HarvestCircleContentTone +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton +import org.harvestcircle.designsystem.primitive.HarvestCircleText import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleUiActions import org.harvestcircle.identities.ui.toUiModel @@ -116,45 +120,62 @@ private fun HarvestCircleShellContent( private fun BootstrapWelcome(dispatch: (HarvestCircleShellIntent) -> Unit) { CanvasScaffold( textSize = TextSizePreference.Default, - header = { ShellText("HarvestCircle", textRole = ShellTextRole.ScreenTitle) }, + header = { HarvestCircleText("HarvestCircle", role = HarvestCircleTextRole.PageTitle) }, body = { Column( Modifier.testTag("bootstrap-welcome"), 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.") - ShellText("You do not need a HarvestCircle account.") - ShellText("A farm opens a round", textRole = ShellTextRole.CardTitle) - ShellText("The farm publishes the available boxes, pickup terms, and price levels.") - ShellText("Buyers make private commitments", textRole = ShellTextRole.CardTitle) - ShellText("Each buyer signs a maximum amount without publishing their identity.") - ShellText("The authority clears the round", textRole = ShellTextRole.CardTitle) - ShellText("The selected authority applies the farm’s signed terms and issues allocations.") - ShellText("Open source · Nostr-based · No managed service required", textRole = ShellTextRole.Secondary) + HarvestCircleText("Coordinate local food with clear, signed terms.", role = HarvestCircleTextRole.SectionTitle) + HarvestCircleText("HarvestCircle helps farms and nearby buyers form one shared order.") + HarvestCircleText("You do not need a HarvestCircle account.") + HarvestCircleText("A farm opens a round", role = HarvestCircleTextRole.SubsectionTitle) + HarvestCircleText("The farm publishes the available boxes, pickup terms, and price levels.") + HarvestCircleText("Buyers make private commitments", role = HarvestCircleTextRole.SubsectionTitle) + HarvestCircleText("Each buyer signs a maximum amount without publishing their identity.") + HarvestCircleText("The authority clears the round", role = HarvestCircleTextRole.SubsectionTitle) + HarvestCircleText("The selected authority applies the farm’s signed terms and issues allocations.") + HarvestCircleText( + "Open source · Nostr-based · No managed service required", + role = HarvestCircleTextRole.BodySmall, + tone = HarvestCircleContentTone.Secondary, + ) } }, actionBar = { Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleDesignTokens.shell.layout.inlineGap)) { - ShellAction("Explore read-only", "Explore read-only", "bootstrap-read-only") { - dispatch(HarvestCircleShellIntent.EnterReadOnly) - } - ShellAction("Create a local Nostr identity", "Create a local Nostr identity", "bootstrap-create") { - dispatch(HarvestCircleShellIntent.Identity(HarvestCircleIntent.ChooseCreateIdentity)) - dispatch( - HarvestCircleShellIntent.Navigation( - NavigationIntent.SelectBootstrapStep(BootstrapStep.CreateIdentity), - ), - ) - } - ShellAction("Import an existing identity", "Import an existing identity", "bootstrap-import") { - dispatch(HarvestCircleShellIntent.Identity(HarvestCircleIntent.ChooseImportIdentity)) - dispatch( - HarvestCircleShellIntent.Navigation( - NavigationIntent.SelectBootstrapStep(BootstrapStep.ImportIdentity), - ), - ) - } + HarvestCircleLabeledButton( + "Explore read-only", + "Explore read-only", + { dispatch(HarvestCircleShellIntent.EnterReadOnly) }, + Modifier.testTag("bootstrap-read-only"), + ) + HarvestCircleLabeledButton( + "Create a local Nostr identity", + "Create a local Nostr identity", + { + dispatch(HarvestCircleShellIntent.Identity(HarvestCircleIntent.ChooseCreateIdentity)) + dispatch( + HarvestCircleShellIntent.Navigation( + NavigationIntent.SelectBootstrapStep(BootstrapStep.CreateIdentity), + ), + ) + }, + Modifier.testTag("bootstrap-create"), + ) + HarvestCircleLabeledButton( + "Import an existing identity", + "Import an existing identity", + { + dispatch(HarvestCircleShellIntent.Identity(HarvestCircleIntent.ChooseImportIdentity)) + dispatch( + HarvestCircleShellIntent.Navigation( + NavigationIntent.SelectBootstrapStep(BootstrapStep.ImportIdentity), + ), + ) + }, + Modifier.testTag("bootstrap-import"), + ) } }, ) @@ -229,7 +250,7 @@ private fun DashboardRoot( ), platformActions = platformActions, ) - else -> ShellText(route.title(), Modifier.testTag("foundation-route-body")) + else -> HarvestCircleText(route.title(), Modifier.testTag("foundation-route-body")) } } }, 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 @@ -12,6 +12,10 @@ import org.harvestcircle.appearance.AppearanceState import org.harvestcircle.appearance.TextSizePreference import org.harvestcircle.application.HarvestCirclePresenterState import org.harvestcircle.application.HarvestCircleRoute +import org.harvestcircle.designsystem.component.HarvestCircleButtonVariant +import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton +import org.harvestcircle.designsystem.primitive.HarvestCircleText import org.harvestcircle.designsystem.theme.HarvestCircleTheme import org.harvestcircle.identities.ui.HarvestCircleUiActions @@ -23,23 +27,28 @@ fun ShellLifecycleCanvas( val presentation = state.route.lifecyclePresentation() CanvasScaffold( textSize = TextSizePreference.Default, - header = { ShellText(presentation.title, textRole = ShellTextRole.ScreenTitle) }, + header = { HarvestCircleText(presentation.title, role = HarvestCircleTextRole.PageTitle) }, body = { Column( Modifier.testTag("lifecycle-${state.route.name.lowercase()}"), verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap), ) { - ShellText(presentation.detail) - state.problem?.let { ShellText(it, Modifier.testTag("lifecycle-problem")) } + HarvestCircleText(presentation.detail) + state.problem?.let { HarvestCircleText(it, Modifier.testTag("lifecycle-problem")) } } }, actionBar = { Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { if (state.lastProblem?.retryable == true) { - ShellButton("Retry", "Retry the last local operation", actions.retryLastCommand) + HarvestCircleLabeledButton("Retry", "Retry the last local operation", actions.retryLastCommand) } if (state.problem != null) { - ShellButton("Dismiss", "Dismiss this problem", actions.dismissProblem, kind = ShellButtonKind.Quiet) + HarvestCircleLabeledButton( + "Dismiss", + "Dismiss this problem", + actions.dismissProblem, + variant = HarvestCircleButtonVariant.Ghost, + ) } } }, @@ -66,15 +75,15 @@ fun ShutdownFailureScreen( Box(Modifier.fillMaxSize().testTag("shutdown-failure")) { CanvasScaffold( textSize = TextSizePreference.Default, - header = { ShellText("HarvestCircle could not close safely", textRole = ShellTextRole.ScreenTitle) }, - body = { ShellText(problem, Modifier.testTag("shutdown-problem")) }, + header = { HarvestCircleText("HarvestCircle could not close safely", role = HarvestCircleTextRole.PageTitle) }, + body = { HarvestCircleText(problem, Modifier.testTag("shutdown-problem")) }, actionBar = { - ShellButton( + HarvestCircleLabeledButton( "Force exit", "Force HarvestCircle to exit", forceExit, Modifier.testTag("force-exit"), - kind = ShellButtonKind.Destructive, + variant = HarvestCircleButtonVariant.Destructive, ) }, ) @@ -90,10 +99,10 @@ private fun FailureCanvas( ) { CanvasScaffold( textSize = TextSizePreference.Default, - header = { ShellText(title, textRole = ShellTextRole.ScreenTitle) }, + header = { HarvestCircleText(title, role = HarvestCircleTextRole.PageTitle) }, body = { Column(Modifier.testTag(tag)) { - ShellText(problem, Modifier.testTag("startup-problem")) + HarvestCircleText(problem, Modifier.testTag("startup-problem")) } }, actionBar = {}, diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelHeader.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelHeader.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import org.harvestcircle.designsystem.component.HarvestCircleContentTone import org.harvestcircle.designsystem.component.HarvestCircleTextRole +import org.harvestcircle.designsystem.component.feedback.HarvestCircleBadge import org.harvestcircle.designsystem.component.navigation.HarvestCircleTab import org.harvestcircle.designsystem.component.navigation.HarvestCircleTabRow import org.harvestcircle.designsystem.primitive.HarvestCircleText @@ -67,7 +68,7 @@ fun MainPanelHeader( ) } } - model.localStatus?.let { ShellBadge(it, Modifier.testTag("main-local-status")) } + model.localStatus?.let { HarvestCircleBadge(it, Modifier.testTag("main-local-status")) } if (model.tabs.isNotEmpty()) { HarvestCircleTabRow { model.tabs.forEach { tab -> diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelTemplates.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelTemplates.kt @@ -2,17 +2,13 @@ package org.harvestcircle.ui.shell import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll 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.designsystem.layout.HarvestCirclePane import org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole import org.harvestcircle.designsystem.theme.HarvestCircleTheme @@ -48,26 +44,6 @@ fun SingleFocusTemplate(content: @Composable () -> Unit) { } @Composable -fun MasterDetailTemplate( - selection: TemplateSelectionKey?, - master: @Composable () -> Unit, - detail: @Composable (TemplateSelectionKey?) -> Unit, -) { - Row(Modifier.fillMaxSize().testTag("template-master-detail")) { - Column( - Modifier - .width(320.dp) - .fillMaxHeight() - .verticalScroll(rememberScrollState()) - .testTag("template-master-list"), - ) { - master() - } - Box(Modifier.weight(1f).fillMaxHeight().testTag("template-detail")) { detail(selection) } - } -} - -@Composable fun TabbedDetailTemplate( tabs: List<TemplateTab>, selected: TemplateSelectionKey, @@ -103,18 +79,3 @@ fun TabbedDetailTemplate( Box(detailModifier) { detail(selected) } } } - -@Composable -fun StudioTemplate( - rail: @Composable () -> Unit, - body: @Composable () -> Unit, - action: @Composable () -> Unit, -) { - Row(Modifier.fillMaxSize().testTag("template-workbench")) { - Box(Modifier.width(280.dp).fillMaxHeight().testTag("template-workbench-rail")) { rail() } - Column(Modifier.weight(1f).fillMaxHeight()) { - Box(Modifier.weight(1f).testTag("template-workbench-body")) { body() } - Box(Modifier.testTag("template-workbench-action")) { action() } - } - } -} diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt @@ -1,177 +0,0 @@ -package org.harvestcircle.ui.shell - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.input.VisualTransformation -import org.harvestcircle.designsystem.component.HarvestCircleButtonVariant -import org.harvestcircle.designsystem.component.HarvestCircleContentTone -import org.harvestcircle.designsystem.component.action.HarvestCircleButton -import org.harvestcircle.designsystem.component.container.HarvestCircleCard -import org.harvestcircle.designsystem.component.container.HarvestCircleCardPadding -import org.harvestcircle.designsystem.component.container.HarvestCircleCardVariant -import org.harvestcircle.designsystem.component.input.HarvestCircleTextField -import org.harvestcircle.designsystem.component.navigation.HarvestCircleTab -import org.harvestcircle.designsystem.component.navigation.HarvestCircleTabRow -import org.harvestcircle.designsystem.component.utility.HarvestCircleHorizontalDivider -import org.harvestcircle.designsystem.primitive.HarvestCircleSurface -import org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole -import org.harvestcircle.designsystem.primitive.HarvestCircleText -import org.harvestcircle.designsystem.component.HarvestCircleTextRole as DesignTextRole - -enum class ShellTextRole { ScreenTitle, SectionTitle, CardTitle, Body, Secondary, Protocol, Button } - -enum class ShellButtonKind { Primary, Secondary, Quiet, Destructive } - -@Composable -fun ShellSurface( - modifier: Modifier = Modifier, - secondary: Boolean = false, - content: @Composable () -> Unit, -) { - HarvestCircleSurface( - modifier = modifier, - role = if (secondary) HarvestCircleSurfaceRole.Sunken else HarvestCircleSurfaceRole.Base, - ) { content() } -} - -@Composable -fun ShellText( - text: String, - modifier: Modifier = Modifier, - textRole: ShellTextRole = ShellTextRole.Body, -) { - val role = - when (textRole) { - ShellTextRole.ScreenTitle -> DesignTextRole.PageTitle - ShellTextRole.SectionTitle -> DesignTextRole.SectionTitle - ShellTextRole.CardTitle -> DesignTextRole.SubsectionTitle - ShellTextRole.Body -> DesignTextRole.Body - ShellTextRole.Secondary -> DesignTextRole.BodySmall - ShellTextRole.Protocol -> DesignTextRole.Code - ShellTextRole.Button -> DesignTextRole.Label - } - HarvestCircleText( - text = text, - modifier = modifier, - role = role, - tone = if (textRole == ShellTextRole.Secondary) HarvestCircleContentTone.Secondary else HarvestCircleContentTone.Inherit, - ) -} - -@Composable -fun ShellButton( - label: String, - description: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, - kind: ShellButtonKind = ShellButtonKind.Secondary, -) { - HarvestCircleButton( - onClick = onClick, - modifier = modifier.semantics { contentDescription = description }, - enabled = enabled, - variant = kind.toDesignVariant(), - ) { - HarvestCircleText(text = label, role = DesignTextRole.Label) - } -} - -@Composable -fun ShellTab( - label: String, - description: String, - selected: Boolean, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, -) { - HarvestCircleTabRow { - HarvestCircleTab( - selected = selected, - onClick = { if (!selected) onClick() }, - label = label, - modifier = modifier.semantics { contentDescription = description }, - enabled = enabled, - ) - } -} - -@Composable -fun ShellIconButton( - glyph: String, - description: String, - onClick: () -> Unit, - modifier: Modifier = Modifier, - enabled: Boolean = true, -) { - ShellButton( - glyph, - description, - onClick, - modifier, - enabled, - kind = ShellButtonKind.Quiet, - ) -} - -@Composable -fun ShellTextField( - value: String, - onValueChange: (String) -> Unit, - label: String, - placeholder: String, - modifier: Modifier = Modifier, - enabled: Boolean = true, - visualTransformation: VisualTransformation = VisualTransformation.None, -) { - HarvestCircleTextField( - value = value, - onValueChange = onValueChange, - modifier = Modifier.fillMaxWidth(), - inputModifier = modifier, - label = label, - placeholder = placeholder, - enabled = enabled, - visualTransformation = visualTransformation, - accessibilityLabel = label, - ) -} - -@Composable -fun ShellBadge( - label: String, - modifier: Modifier = Modifier, -) { - HarvestCircleCard( - modifier = modifier.semantics(mergeDescendants = true) {}, - variant = HarvestCircleCardVariant.Outlined, - padding = HarvestCircleCardPadding.Compact, - ) { - HarvestCircleText(label, role = DesignTextRole.LabelSmall, tone = HarvestCircleContentTone.Secondary) - } -} - -@Composable -fun ShellCard( - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - HarvestCircleCard(modifier = modifier) { content() } -} - -@Composable -fun ShellDivider(modifier: Modifier = Modifier) { - HarvestCircleHorizontalDivider(modifier) -} - -private fun ShellButtonKind.toDesignVariant(): HarvestCircleButtonVariant = - when (this) { - ShellButtonKind.Primary -> HarvestCircleButtonVariant.Primary - ShellButtonKind.Secondary -> HarvestCircleButtonVariant.Secondary - ShellButtonKind.Quiet -> HarvestCircleButtonVariant.Ghost - ShellButtonKind.Destructive -> HarvestCircleButtonVariant.Destructive - } diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/LifecycleCanvasesTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/LifecycleCanvasesTest.kt @@ -45,7 +45,7 @@ class LifecycleCanvasesTest { } @Test - fun standaloneFailuresUseTheActiveShellControls() = + fun standaloneFailuresUseTheActiveDesignSystem() = runComposeUiTest { var forced = 0 setHarvestCircleContent { ShutdownFailureScreen("Shutdown timed out.") { forced += 1 } } diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/MainPanelTemplatesTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/MainPanelTemplatesTest.kt @@ -1,37 +1,21 @@ package org.harvestcircle.ui.shell -import androidx.compose.ui.semantics.SemanticsActions import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.onAllNodesWithTag -import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.v2.runComposeUiTest import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue @OptIn(ExperimentalTestApi::class) class MainPanelTemplatesTest { @Test - fun masterDetailOwnsItsInternalListScrollAndTypedSelection() = - runComposeUiTest { - val selection = TemplateSelectionKey("selected") - var observed: TemplateSelectionKey? = null - setHarvestCircleContent { MasterDetailTemplate(selection, master = {}, detail = { observed = it }) } - assertEquals(selection, observed) - assertTrue(onNodeWithTag("template-master-list").fetchSemanticsNode().config.contains(SemanticsActions.ScrollBy)) - } - - @Test - fun tabbedAndWorkbenchTemplatesExposeOnlyStructuralSlots() = + fun tabbedDetailTemplateExposesOnlyProductOwnedStructuralSlots() = runComposeUiTest { val tab = TemplateTab(TemplateSelectionKey("overview"), "Overview") setHarvestCircleContent { TabbedDetailTemplate(listOf(tab), tab.key, tabRail = { _, _ -> }, detail = {}) - StudioTemplate(rail = {}, body = {}, action = {}) } onAllNodesWithTag("template-tabs").assertCountEquals(1) - onAllNodesWithTag("template-workbench-rail").assertCountEquals(1) - onAllNodesWithTag("template-workbench-action").assertCountEquals(1) + onAllNodesWithTag("template-tab-detail").assertCountEquals(1) } } diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/OwnedControlsUiTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/OwnedControlsUiTest.kt @@ -0,0 +1,115 @@ +package org.harvestcircle.ui.shell + +import androidx.compose.foundation.layout.Column +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertHeightIsAtLeast +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.v2.runComposeUiTest +import androidx.compose.ui.unit.dp +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.ThemePreference +import org.harvestcircle.designsystem.component.HarvestCircleButtonVariant +import org.harvestcircle.designsystem.component.action.HarvestCircleIconButton +import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton +import org.harvestcircle.designsystem.component.input.HarvestCircleTextField +import org.harvestcircle.designsystem.component.navigation.HarvestCircleTab +import org.harvestcircle.designsystem.component.navigation.HarvestCircleTabRow +import org.harvestcircle.designsystem.icon.HarvestCircleIcons +import kotlin.test.Test +import kotlin.test.assertFalse + +@OptIn(ExperimentalTestApi::class) +class OwnedControlsUiTest { + @Test + fun hcSc010ControlsExposeTargetsSelectionDisabledStateAndFieldCopy() = + runComposeUiTest { + setHarvestCircleContent { + Column { + HarvestCircleTabRow { + HarvestCircleTab( + label = "Today", + selected = true, + onClick = {}, + modifier = + Modifier + .semantics { contentDescription = "Show Today" } + .testTag("control-tab"), + ) + } + HarvestCircleLabeledButton( + "Unavailable", + "Unavailable action", + {}, + Modifier.testTag("control-disabled"), + enabled = false, + ) + HarvestCircleTextField( + "", + {}, + label = "Nostr reference", + placeholder = "npub1…", + inputModifier = Modifier.testTag("control-field"), + ) + HarvestCircleIconButton( + onClick = {}, + icon = HarvestCircleIcons.Info, + label = "Help", + modifier = Modifier.testTag("control-icon"), + ) + } + } + + onNodeWithTag("control-tab") + .assertIsSelected() + .assertIsEnabled() + .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Tab)) + .assertHeightIsAtLeast(32.dp) + onNodeWithTag("control-disabled").assertIsNotEnabled() + assertFalse(onNodeWithTag("control-disabled").fetchSemanticsNode().config.contains(SemanticsProperties.Selected)) + onNodeWithTag("control-field").assertTextContains("npub1…") + onNodeWithTag("control-icon").assertHeightIsAtLeast(32.dp) + } + + @Test + fun ownedControlsRetainActionSemanticsInLightAndDark() { + listOf(false, true).forEach { systemDark -> + runComposeUiTest { + setHarvestCircleContent { + HarvestCircleTheme(AppearanceState(theme = ThemePreference.System), systemDark = systemDark) { + Column { + HarvestCircleLabeledButton( + "Primary", + "Primary action", + {}, + Modifier.testTag("control-primary"), + variant = HarvestCircleButtonVariant.Primary, + ) + HarvestCircleLabeledButton( + "Destructive", + "Destructive action", + {}, + Modifier.testTag("control-destructive"), + variant = HarvestCircleButtonVariant.Destructive, + ) + } + } + } + + onNodeWithTag("control-primary").assertIsEnabled().assertTextContains("Primary") + onNodeWithTag("control-destructive").assertIsEnabled().assertTextContains("Destructive") + } + } + } +} diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertHeightIsAtLeast @@ -28,6 +29,7 @@ import org.harvestcircle.application.SessionLifecycle import org.harvestcircle.application.ShellFocusTarget import org.harvestcircle.application.SnapshotRevision import org.harvestcircle.application.StatusOverlayKey +import org.harvestcircle.designsystem.component.action.HarvestCircleLabeledButton import org.harvestcircle.identities.ui.HarvestCirclePlatformActions import org.harvestcircle.identities.ui.HarvestCircleUiActions import kotlin.test.Test @@ -39,7 +41,12 @@ class ShellAccessibilityUiTest { runComposeUiTest { setHarvestCircleContent { RouteFocusTarget("today", "Today main content") { - ShellAction("Action", "Accessible action", "accessible-action", onClick = {}) + HarvestCircleLabeledButton( + "Action", + "Accessible action", + {}, + Modifier.testTag("accessible-action"), + ) } } @@ -120,15 +127,18 @@ class ShellAccessibilityUiTest { val registry = remember { ShellFocusRegistry() } CompositionLocalProvider(LocalShellFocusRegistry provides registry) { RouteFocusTarget("today", "Today main content") { - ShellAction( - "Open", - "Open dialog", - "restore-trigger", - modifier = Modifier.shellFocusTarget(ShellFocusTarget.TodayReference), - ) { - restoreTarget = null - modalOpen = true - } + HarvestCircleLabeledButton( + label = "Open", + accessibilityLabel = "Open dialog", + modifier = + Modifier + .shellFocusTarget(ShellFocusTarget.TodayReference) + .testTag("restore-trigger"), + onClick = { + restoreTarget = null + modalOpen = true + }, + ) if (modalOpen) { FoundationOverlayHost( org.harvestcircle.application.OverlayState( diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt @@ -1,85 +0,0 @@ -package org.harvestcircle.ui.shell - -import androidx.compose.foundation.layout.Column -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.ExperimentalTestApi -import androidx.compose.ui.test.SemanticsMatcher -import androidx.compose.ui.test.assert -import androidx.compose.ui.test.assertHeightIsAtLeast -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.assertIsSelected -import androidx.compose.ui.test.assertTextContains -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.v2.runComposeUiTest -import androidx.compose.ui.unit.dp -import org.harvestcircle.appearance.AppearanceState -import org.harvestcircle.appearance.ThemePreference -import kotlin.test.Test -import kotlin.test.assertFalse - -@OptIn(ExperimentalTestApi::class) -class ShellControlsUiTest { - @Test - fun hcSc010ControlsExposeTargetsSelectionDisabledStateAndFieldCopy() = - runComposeUiTest { - setHarvestCircleContent { - Column { - ShellTab( - "Today", - "Show Today", - selected = true, - onClick = {}, - modifier = Modifier.testTag("control-tab"), - ) - ShellButton("Unavailable", "Unavailable action", {}, Modifier.testTag("control-disabled"), enabled = false) - ShellTextField("", {}, "Nostr reference", "npub1…", Modifier.testTag("control-field")) - ShellIconButton("?", "Help", {}, Modifier.testTag("control-icon")) - } - } - - onNodeWithTag("control-tab") - .assertIsSelected() - .assertIsEnabled() - .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Tab)) - .assertHeightIsAtLeast(32.dp) - onNodeWithTag("control-disabled").assertIsNotEnabled() - assertFalse(onNodeWithTag("control-disabled").fetchSemanticsNode().config.contains(SemanticsProperties.Selected)) - onNodeWithTag("control-field").assertTextContains("npub1…") - onNodeWithTag("control-icon").assertHeightIsAtLeast(32.dp) - } - - @Test - fun ownedControlsRetainActionSemanticsInLightAndDark() { - listOf(false, true).forEach { systemDark -> - runComposeUiTest { - setHarvestCircleContent { - HarvestCircleTheme(AppearanceState(theme = ThemePreference.System), systemDark = systemDark) { - Column { - ShellButton( - "Primary", - "Primary action", - {}, - Modifier.testTag("control-primary"), - kind = ShellButtonKind.Primary, - ) - ShellButton( - "Destructive", - "Destructive action", - {}, - Modifier.testTag("control-destructive"), - kind = ShellButtonKind.Destructive, - ) - } - } - } - - onNodeWithTag("control-primary").assertIsEnabled().assertTextContains("Primary") - onNodeWithTag("control-destructive").assertIsEnabled().assertTextContains("Destructive") - } - } - } -} diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -406,11 +406,6 @@ fn approved_legacy_product_fragments( "app/shared/src/commonTest/kotlin/org/harvestcircle/product/SurfaceRegistryTest.kt" => { vec![format!("round_{legacy}_screen")] } - "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/MainPanelTemplates.kt" - | "app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/MainPanelTemplatesTest.kt" => - { - vec![format!("{}Template", title_case(legacy))] - } "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationSettingsScreen.kt" => { vec![legacy_repository.to_owned()] @@ -453,7 +448,7 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St &["hcSc008", "hcSc009"], ), ( - "app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellControlsUiTest.kt", + "app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/OwnedControlsUiTest.kt", &["hcSc010"], ), ( @@ -728,10 +723,7 @@ fn product_shell_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St == "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/primitive/HarvestCircleText.kt"; let approved_input_primitive = path == "app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/component/input/HarvestCircleTextField.kt"; - if is_production_compose(path, &source) - && path - != "app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellControls.kt" - { + if is_production_compose(path, &source) { if !approved_text_primitive && contains_direct_call(&compact, "BasicText(") { findings.push(format!( "{path}: BasicText bypasses the shell primitive adapter"