app

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

commit b190edc5865df95220e7cd21850c95cb20f1f1dd
parent e47adf2001fce5efd8e90e9ead33b80cf04bd700
Author: triesap <tyson@radroots.org>
Date:   Thu, 13 Aug 2026 17:51:41 +0000

test(ui): verify the live shell visuals

Why:
- The prior goldens rendered a synthetic component sample instead of the application users launch.
- Live-state review exposed the status banner obscuring global shell chrome.

What:
- Render the real Today shell in light and dark macOS goldens.
- Place dashboard status banners inside the main pane below shell chrome.
- Bind verified golden hashes to the complete governed Studio source snapshot.
- Make the source audit reject synthetic golden fixtures.

Verification:
- make format
- focused FoundationOverlayHostTest and ShellAccessibilityUiTest
- make design-goldens-update
- make design-source-check
- focused HarvestCircleMacGoldenTest
- visual inspection of both macOS goldens
- git diff --check

Diffstat:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/FoundationOverlayHost.kt | 33++++++++++++++++++++++-----------
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 98+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleMacGoldenTest.kt | 130+++++++++++++++++++++++++++++--------------------------------------------------
Mapp/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-dark.png | 0
Mapp/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-light.png | 0
Mconfig/design/source_baseline_v1.toml | 8++++----
Mtools/xtask/src/lib.rs | 23+++++++++++++++++------
7 files changed, 145 insertions(+), 147 deletions(-)

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 @@ -42,19 +42,11 @@ import org.harvestcircle.designsystem.shell.HarvestCircleShellTextRole fun FoundationOverlayHost( state: OverlayState, status: ShellStatusModel, + showBanner: Boolean = true, onIntent: (OverlayIntent) -> Unit, ) { - status.banner?.let { banner -> - HarvestCircleShellBanner( - message = banner.message, - modifier = - Modifier - .padding(16.dp) - .semantics { contentDescription = "Status: ${banner.title}. ${banner.message}" } - .testTag("global-status-banner"), - tone = banner.severity.toShellBannerTone(), - title = banner.title, - ) + if (showBanner) { + FoundationStatusBanner(status) } state.current?.let { overlay -> val overlayBusy = (overlay as? FoundationOverlay.ConfirmAction)?.busy == true @@ -92,6 +84,25 @@ fun FoundationOverlayHost( } @Composable +fun FoundationStatusBanner( + status: ShellStatusModel, + modifier: Modifier = Modifier, +) { + status.banner?.let { banner -> + HarvestCircleShellBanner( + message = banner.message, + modifier = + modifier + .padding(16.dp) + .semantics { contentDescription = "Status: ${banner.title}. ${banner.message}" } + .testTag("global-status-banner"), + tone = banner.severity.toShellBannerTone(), + title = banner.title, + ) + } +} + +@Composable private fun ConfirmOverlay( overlay: FoundationOverlay.ConfirmAction, busy: Boolean, 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue @@ -117,7 +118,11 @@ private fun HarvestCircleShellContent( } } ShellFocusRestorer(state.overlays.restoreFocus, fallback) - FoundationOverlayHost(state.overlays, deriveShellStatus(state)) { + FoundationOverlayHost( + state = state.overlays, + status = deriveShellStatus(state), + showBanner = state.root !is ShellRoot.Dashboard, + ) { dispatch(HarvestCircleShellIntent.Overlay(it)) } } @@ -271,53 +276,58 @@ private fun DashboardRoot( } }, mainBody = { - RouteFocusTarget( - route.toString(), - "${route.title()} main content", - ) { - when (route) { - AppRoute.PersonalToday -> - FoundationTodayScreen( - model = FoundationTodayModel(todayContext(state)), - openNostrReference = { - dispatch( - HarvestCircleShellIntent.Overlay( - OverlayIntent.OpenReference(ShellFocusTarget.TodayReference), - ), - ) - }, - ) - AppRoute.Network -> - FoundationNetworkScreen( - foundationNetworkModel(state), - refreshProfile = identityActions.refreshActiveProfile, - signOut = identityActions.signOut, - section = networkSection, - onSectionSelected = { networkSection = it }, - showSectionTabs = false, - ) - AppRoute.Settings -> - FoundationSettingsScreen( - section = root.navigation.settings.section, - appearance = state.appearance, - buildInfo = state.buildInfo, - actions = - FoundationSettingsActions( - selectSection = { + Column(Modifier.fillMaxSize()) { + FoundationStatusBanner(status, Modifier.fillMaxWidth()) + Box(Modifier.weight(1f).fillMaxSize()) { + RouteFocusTarget( + route.toString(), + "${route.title()} main content", + ) { + when (route) { + AppRoute.PersonalToday -> + FoundationTodayScreen( + model = FoundationTodayModel(todayContext(state)), + openNostrReference = { dispatch( - HarvestCircleShellIntent.Navigation( - NavigationIntent.SelectSettingsSection(it), + HarvestCircleShellIntent.Overlay( + OverlayIntent.OpenReference(ShellFocusTarget.TodayReference), ), ) }, - setTheme = { dispatch(HarvestCircleShellIntent.SetTheme(it)) }, - setTextSize = { dispatch(HarvestCircleShellIntent.SetTextSize(it)) }, - setMotion = { dispatch(HarvestCircleShellIntent.SetMotion(it)) }, - ), - platformActions = platformActions, - showSectionTabs = false, - ) - else -> HarvestCircleShellText(route.title(), Modifier.testTag("foundation-route-body")) + ) + AppRoute.Network -> + FoundationNetworkScreen( + foundationNetworkModel(state), + refreshProfile = identityActions.refreshActiveProfile, + signOut = identityActions.signOut, + section = networkSection, + onSectionSelected = { networkSection = it }, + showSectionTabs = false, + ) + AppRoute.Settings -> + FoundationSettingsScreen( + section = root.navigation.settings.section, + appearance = state.appearance, + buildInfo = state.buildInfo, + actions = + FoundationSettingsActions( + selectSection = { + dispatch( + HarvestCircleShellIntent.Navigation( + NavigationIntent.SelectSettingsSection(it), + ), + ) + }, + setTheme = { dispatch(HarvestCircleShellIntent.SetTheme(it)) }, + setTextSize = { dispatch(HarvestCircleShellIntent.SetTextSize(it)) }, + setMotion = { dispatch(HarvestCircleShellIntent.SetMotion(it)) }, + ), + platformActions = platformActions, + showSectionTabs = false, + ) + else -> HarvestCircleShellText(route.title(), Modifier.testTag("foundation-route-body")) + } + } } } }, diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleMacGoldenTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleMacGoldenTest.kt @@ -1,14 +1,7 @@ package org.harvestcircle.ui.shell -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.toArgb @@ -19,23 +12,18 @@ import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.unit.dp -import org.harvestcircle.designsystem.component.HarvestCircleButtonVariant -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.component.feedback.HarvestCircleBanner -import org.harvestcircle.designsystem.component.feedback.HarvestCircleBannerTone -import org.harvestcircle.designsystem.component.navigation.HarvestCircleNavigationItem -import org.harvestcircle.designsystem.layout.HarvestCirclePane -import org.harvestcircle.designsystem.layout.HarvestCircleSidebar -import org.harvestcircle.designsystem.layout.HarvestCircleSidebarSectionHeader -import org.harvestcircle.designsystem.layout.HarvestCircleToolbar -import org.harvestcircle.designsystem.primitive.HarvestCircleText -import org.harvestcircle.designsystem.theme.HarvestCircleDesignTheme -import org.harvestcircle.designsystem.theme.HarvestCircleTheme -import org.harvestcircle.designsystem.theme.HarvestCircleThemeConfig -import org.harvestcircle.designsystem.theme.HarvestCircleThemeMode +import org.harvestcircle.appearance.AppearanceState +import org.harvestcircle.appearance.ThemePreference +import org.harvestcircle.application.ApplicationLifecycle +import org.harvestcircle.application.ApplicationSnapshot +import org.harvestcircle.application.BuildInfo +import org.harvestcircle.application.HarvestCirclePresenterState +import org.harvestcircle.application.HarvestCircleShellState +import org.harvestcircle.application.SessionLifecycle +import org.harvestcircle.application.ShellSessionState +import org.harvestcircle.application.SnapshotRevision +import org.harvestcircle.identities.ui.HarvestCirclePlatformActions +import org.harvestcircle.identities.ui.HarvestCircleUiActions import java.awt.image.BufferedImage import java.io.File import javax.imageio.ImageIO @@ -47,16 +35,16 @@ import kotlin.test.assertNotNull @OptIn(ExperimentalTestApi::class) class HarvestCircleMacGoldenTest { @Test - fun macosAarch64LightAndDarkReferenceSurfacesMatch() { + fun macosAarch64LiveTodayShellMatches() { val updating = System.getProperty("harvestcircle.updateMacosGoldens") == "true" if (!isMacosAarch64()) { check(!updating) { "HarvestCircle design goldens may only be updated on macos-aarch64" } return } - listOf(HarvestCircleThemeMode.Light, HarvestCircleThemeMode.Dark).forEach { mode -> - val actual = captureReferenceSurface(mode) - val resourceName = "goldens/macos-aarch64/design-surface-${mode.name.lowercase()}.png" + listOf(ThemePreference.Light, ThemePreference.Dark).forEach { theme -> + val actual = captureLiveTodayShell(theme) + val resourceName = "goldens/macos-aarch64/design-surface-${theme.name.lowercase()}.png" if (updating) { val projectDir = assertNotNull(System.getProperty("harvestcircle.projectDir")) val output = File(projectDir, "app/shared/src/desktopTest/resources/$resourceName") @@ -78,62 +66,17 @@ class HarvestCircleMacGoldenTest { } @OptIn(ExperimentalTestApi::class) -private fun captureReferenceSurface(mode: HarvestCircleThemeMode): BufferedImage { +private fun captureLiveTodayShell(theme: ThemePreference): BufferedImage { lateinit var captured: ImageBitmap runComposeUiTest { setContent { - HarvestCircleDesignTheme(HarvestCircleThemeConfig(mode = mode)) { - Row(Modifier.size(width = 760.dp, height = 420.dp).testTag("golden-surface")) { - HarvestCircleSidebar(Modifier.width(180.dp)) { - HarvestCircleSidebarSectionHeader("HarvestCircle") - HarvestCircleNavigationItem(true, {}, "Today") - HarvestCircleNavigationItem(false, {}, "Network") - HarvestCircleNavigationItem(false, {}, "Settings") - Spacer(Modifier.weight(1f)) - HarvestCircleText( - "Local-first ยท Nostr", - modifier = Modifier.padding(HarvestCircleTheme.foundation.spacing.md), - role = HarvestCircleTextRole.LabelSmall, - tone = HarvestCircleContentTone.Muted, - ) - } - Column(Modifier.weight(1f).fillMaxHeight()) { - HarvestCircleToolbar { - HarvestCircleText("Today", role = HarvestCircleTextRole.SectionTitle) - Spacer(Modifier.weight(1f)) - HarvestCircleBadge("Signed out") - } - HarvestCirclePane( - modifier = Modifier.fillMaxSize(), - role = org.harvestcircle.designsystem.primitive.HarvestCircleSurfaceRole.Canvas, - ) { - Column(verticalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.contentGap)) { - HarvestCircleText("Coordinate local food", role = HarvestCircleTextRole.PageTitle) - HarvestCircleText("Clear, signed terms for farms and nearby buyers.") - HarvestCircleBanner( - message = "No managed HarvestCircle service is configured.", - tone = HarvestCircleBannerTone.Info, - title = "Local runtime", - ) - Row(horizontalArrangement = Arrangement.spacedBy(HarvestCircleTheme.shell.layout.inlineGap)) { - HarvestCircleLabeledButton("Open a Nostr reference", "Open a Nostr reference", {}) - HarvestCircleLabeledButton( - "Explore circles", - "Explore circles", - {}, - enabled = false, - variant = HarvestCircleButtonVariant.Secondary, - ) - } - HarvestCircleText( - "Not available in this build.", - role = HarvestCircleTextRole.BodySmall, - tone = HarvestCircleContentTone.Secondary, - ) - } - } - } - } + Box(Modifier.size(width = 1280.dp, height = 800.dp).testTag("golden-surface")) { + HarvestCircleShell( + state = liveTodayState(theme), + identityActions = HarvestCircleUiActions(), + platformActions = HarvestCirclePlatformActions(), + dispatch = {}, + ) } } waitForIdle() @@ -142,6 +85,29 @@ private fun captureReferenceSurface(mode: HarvestCircleThemeMode): BufferedImage return captured.toBufferedImage() } +private fun liveTodayState(theme: ThemePreference): HarvestCircleShellState = + HarvestCircleShellState( + identity = + HarvestCirclePresenterState( + ApplicationSnapshot( + revision = SnapshotRevision(1UL), + lifecycle = ApplicationLifecycle.Ready, + lifecycleProblem = null, + configuredRelays = emptyList(), + identities = emptyList(), + selectedIdentityId = null, + session = SessionLifecycle.SignedOut, + sessionSubjectIdentityId = null, + sessionProblem = null, + activeIdentity = null, + recoverableProblem = null, + ), + ), + buildInfo = BuildInfo.unknown(), + session = ShellSessionState(readOnly = true), + appearance = AppearanceState(theme = theme), + ) + private fun ImageBitmap.toBufferedImage(): BufferedImage { val pixels = toPixelMap() return BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB).also { image -> diff --git a/app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-dark.png b/app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-dark.png Binary files differ. diff --git a/app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-light.png b/app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-light.png Binary files differ. diff --git a/config/design/source_baseline_v1.toml b/config/design/source_baseline_v1.toml @@ -7,10 +7,10 @@ snapshot_sha256 = "c2fe49f3c3ea43105cb2fff4a67c7cd9c21561c71825440a91654a0c1b12e snapshot_file_count = 102 source_license = "GPL-3.0-only" golden_host = "macos-aarch64" -golden_status = "corrective-pending" -golden_source_snapshot_sha256 = "914c5d3b81c95bded19e36044cece8cff93e74cce0d4089324bb2effa4eebb93" -golden_light_sha256 = "844a42a37d05671a9665a75214ee40f9c23dafd90c8a5e199d831daede3faf20" -golden_dark_sha256 = "ce06a1ae820005adddebd8f627b5402279d3365762305f8dd64004612322eae7" +golden_status = "verified" +golden_source_snapshot_sha256 = "c2fe49f3c3ea43105cb2fff4a67c7cd9c21561c71825440a91654a0c1b12e3b8" +golden_light_sha256 = "7df7cbd0a69b62deba5f924586f19537f09d03afbb2176d9aa081acf5b88ea94" +golden_dark_sha256 = "f1e88380060c099ee3a2ba8e90f3a24bd1d4b0e2ce90614456fbd7f379c77ca8" [[mapping]] source = "core/designsystem/src/commonMain" diff --git a/tools/xtask/src/lib.rs b/tools/xtask/src/lib.rs @@ -908,8 +908,6 @@ fn design_source_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St } let source = read_text(root, PATH); let expected_snapshot = "c2fe49f3c3ea43105cb2fff4a67c7cd9c21561c71825440a91654a0c1b12e3b8"; - let pending_golden_snapshot = - "914c5d3b81c95bded19e36044cece8cff93e74cce0d4089324bb2effa4eebb93"; let required_scalars = [ "schema = \"harvestcircle.design_source_baseline.v1\"", "source_product = \"Studio\"", @@ -919,7 +917,7 @@ fn design_source_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St "snapshot_file_count = 102", "source_license = \"GPL-3.0-only\"", "golden_host = \"macos-aarch64\"", - "golden_status = \"corrective-pending\"", + "golden_status = \"verified\"", ]; for scalar in required_scalars { if source.lines().filter(|line| line.trim() == scalar).count() != 1 { @@ -928,7 +926,7 @@ fn design_source_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St } for (key, digest) in [ ("snapshot_sha256", expected_snapshot), - ("golden_source_snapshot_sha256", pending_golden_snapshot), + ("golden_source_snapshot_sha256", expected_snapshot), ] { let expected = format!("{key} = \"{digest}\""); if source @@ -944,12 +942,12 @@ fn design_source_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St ( "app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-light.png", "golden_light_sha256", - "844a42a37d05671a9665a75214ee40f9c23dafd90c8a5e199d831daede3faf20", + "7df7cbd0a69b62deba5f924586f19537f09d03afbb2176d9aa081acf5b88ea94", ), ( "app/shared/src/desktopTest/resources/goldens/macos-aarch64/design-surface-dark.png", "golden_dark_sha256", - "ce06a1ae820005adddebd8f627b5402279d3365762305f8dd64004612322eae7", + "f1e88380060c099ee3a2ba8e90f3a24bd1d4b0e2ce90614456fbd7f379c77ca8", ), ] { let authority = format!("{key} = \"{sha256}\""); @@ -967,6 +965,19 @@ fn design_source_audit(root: &Path, inventory: &Inventory, findings: &mut Vec<St findings.push(format!("{path}: macOS golden is missing or changed")); } } + let golden_test = read_text( + root, + "app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/HarvestCircleMacGoldenTest.kt", + ); + if !golden_test.contains("HarvestCircleShell(") + || !golden_test.contains("liveTodayState(") + || golden_test.contains("captureReferenceSurface(") + { + findings.push( + "HarvestCircleMacGoldenTest.kt: golden must render a live application shell state" + .to_owned(), + ); + } let mappings = design_source_mappings(&source, PATH, findings); let required = [ (