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:
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 = [
(