app

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

commit 3a205f0693374301a6950a50d853acdeaf1c9e7f
parent 175f655156d5132e5a159a39e10e3e2aa7e6f188
Author: triesap <tyson@radroots.org>
Date:   Fri, 14 Aug 2026 17:31:44 +0000

design: model host window chrome exclusions

- Define immutable physical-edge exclusion and clearance contracts
- Resolve top-band and regional clearance with fail-closed geometry
- Expose the design system directly to the desktop application
- Cover narrow, tall, left, right, and invalid geometry cases

Diffstat:
Aapp/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleWindowChrome.kt | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleWindowChromeTest.kt | 107+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbuild-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt | 6++++--
Mbuild-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt | 1+
4 files changed, 210 insertions(+), 2 deletions(-)

diff --git a/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleWindowChrome.kt b/app/design_system/src/commonMain/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleWindowChrome.kt @@ -0,0 +1,98 @@ +package org.harvestcircle.designsystem.layout + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified + +/** The physical window edge occupied by host-owned chrome. */ +public enum class HarvestCircleWindowChromeEdge { + Left, + Right, +} + +/** + * A host-owned rectangle at the top of a window that application foreground content must avoid. + * + * [width] and [height] include the host's required safety gap. Backgrounds may remain full-bleed. + */ +@Immutable +public data class HarvestCircleWindowChromeExclusion( + public val edge: HarvestCircleWindowChromeEdge, + public val width: Dp, + public val height: Dp, +) { + init { + requireValidWindowChromeDimension("width", width) + requireValidWindowChromeDimension("height", height) + } + + public companion object { + public val None: HarvestCircleWindowChromeExclusion = + HarvestCircleWindowChromeExclusion( + edge = HarvestCircleWindowChromeEdge.Left, + width = 0.dp, + height = 0.dp, + ) + } +} + +/** Resolved physical clearances for a top-edge region inside a window. */ +@Immutable +public data class HarvestCircleWindowChromeClearance( + public val topBandHeight: Dp, + public val left: Dp, + public val right: Dp, +) + +/** + * Resolves the portion of [exclusion] intersecting a top-edge region. + * + * All horizontal inputs use window coordinates. The result is clamped to the region, so extremely + * narrow windows fail safely without producing negative or impossible padding. + */ +public fun resolveHarvestCircleWindowChromeClearance( + exclusion: HarvestCircleWindowChromeExclusion, + windowWidth: Dp, + regionLeft: Dp, + regionWidth: Dp, + minimumTopBandHeight: Dp, +): HarvestCircleWindowChromeClearance { + requireValidWindowChromeDimension("windowWidth", windowWidth) + requireValidWindowChromeDimension("regionLeft", regionLeft) + requireValidWindowChromeDimension("regionWidth", regionWidth) + requireValidWindowChromeDimension("minimumTopBandHeight", minimumTopBandHeight) + require(regionLeft + regionWidth <= windowWidth) { + "Window chrome region must remain inside the window" + } + + val exclusionWidth = minOf(exclusion.width, windowWidth) + val regionRight = regionLeft + regionWidth + val left = + if (exclusion.edge == HarvestCircleWindowChromeEdge.Left) { + (exclusionWidth - regionLeft).coerceIn(0.dp, regionWidth) + } else { + 0.dp + } + val right = + if (exclusion.edge == HarvestCircleWindowChromeEdge.Right) { + (regionRight - (windowWidth - exclusionWidth)).coerceIn(0.dp, regionWidth) + } else { + 0.dp + } + + return HarvestCircleWindowChromeClearance( + topBandHeight = maxOf(minimumTopBandHeight, exclusion.height), + left = left, + right = right, + ) +} + +private fun requireValidWindowChromeDimension( + name: String, + value: Dp, +) { + require(value.isSpecified && value.value.isFinite() && value >= 0.dp) { + "Window chrome $name must be a finite, non-negative Dp value" + } +} diff --git a/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleWindowChromeTest.kt b/app/design_system/src/commonTest/kotlin/org/harvestcircle/designsystem/layout/HarvestCircleWindowChromeTest.kt @@ -0,0 +1,107 @@ +package org.harvestcircle.designsystem.layout + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class HarvestCircleWindowChromeTest { + @Test + fun noExclusionKeepsTheDesignBandAndAddsNoClearance() { + val clearance = resolve(regionLeft = 0.dp, regionWidth = 1280.dp) + + assertEquals(48.dp, clearance.topBandHeight) + assertEquals(0.dp, clearance.left) + assertEquals(0.dp, clearance.right) + } + + @Test + fun leftExclusionClearsOnlyTheIntersectingPortionOfEachRegion() { + val exclusion = + HarvestCircleWindowChromeExclusion( + HarvestCircleWindowChromeEdge.Left, + width = 112.dp, + height = 40.dp, + ) + + assertEquals(112.dp, resolve(exclusion, regionLeft = 0.dp, regionWidth = 1280.dp).left) + assertEquals(40.dp, resolve(exclusion, regionLeft = 72.dp, regionWidth = 1208.dp).left) + assertEquals(0.dp, resolve(exclusion, regionLeft = 232.dp, regionWidth = 1048.dp).left) + } + + @Test + fun rightExclusionResolvesAgainstPhysicalWindowCoordinates() { + val exclusion = + HarvestCircleWindowChromeExclusion( + HarvestCircleWindowChromeEdge.Right, + width = 96.dp, + height = 40.dp, + ) + + val clearance = resolve(exclusion, regionLeft = 72.dp, regionWidth = 1208.dp) + + assertEquals(0.dp, clearance.left) + assertEquals(96.dp, clearance.right) + } + + @Test + fun tallerHostChromeExpandsTheTopBand() { + val exclusion = + HarvestCircleWindowChromeExclusion( + HarvestCircleWindowChromeEdge.Left, + width = 112.dp, + height = 56.dp, + ) + + assertEquals(56.dp, resolve(exclusion, regionLeft = 0.dp, regionWidth = 1280.dp).topBandHeight) + } + + @Test + fun exclusionAndClearanceClampToNarrowWindowsAndRegions() { + val exclusion = + HarvestCircleWindowChromeExclusion( + HarvestCircleWindowChromeEdge.Left, + width = 112.dp, + height = 40.dp, + ) + + assertEquals( + 24.dp, + resolve( + exclusion = exclusion, + windowWidth = 80.dp, + regionLeft = 56.dp, + regionWidth = 24.dp, + ).left, + ) + } + + @Test + fun invalidGeometryFailsClosed() { + assertFailsWith<IllegalArgumentException> { + HarvestCircleWindowChromeExclusion( + HarvestCircleWindowChromeEdge.Left, + width = (-1).dp, + height = 40.dp, + ) + } + assertFailsWith<IllegalArgumentException> { + resolve(regionLeft = 1200.dp, regionWidth = 100.dp) + } + } + + private fun resolve( + exclusion: HarvestCircleWindowChromeExclusion = HarvestCircleWindowChromeExclusion.None, + windowWidth: Dp = 1280.dp, + regionLeft: Dp, + regionWidth: Dp, + ): HarvestCircleWindowChromeClearance = + resolveHarvestCircleWindowChromeClearance( + exclusion = exclusion, + windowWidth = windowWidth, + regionLeft = regionLeft, + regionWidth = regionWidth, + minimumTopBandHeight = 48.dp, + ) +} diff --git a/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt b/build-logic/plugins/src/functionalTest/kotlin/org/harvestcircle/buildlogic/plugins/ConventionPluginSmokeTest.kt @@ -46,7 +46,7 @@ class ConventionPluginSmokeTest { if (pluginId == "org.harvestcircle.build.design-catalog") { append("include(\":app:design_system\", \":tools:design_catalog\")\n") } - if (desktopFixture) append("include(\":app:shared\", \":app:desktop\")\n") + if (desktopFixture) append("include(\":app:design_system\", \":app:shared\", \":app:desktop\")\n") }, ) if ( @@ -129,6 +129,8 @@ class ConventionPluginSmokeTest { withUnitTest: Boolean = true, ) { fixture.resolve("build.gradle.kts").writeText("plugins { id(\"org.harvestcircle.build.root\") }\n") + fixture.resolve("app/design_system").createDirectories().resolve("build.gradle.kts") + .writeText("plugins { `java-library` }\n") fixture.resolve("app/shared").createDirectories().resolve("build.gradle.kts").writeText("plugins { `java-library` }\n") fixture.resolve("app/desktop").createDirectories() fixture.resolve("config/product").createDirectories().resolve("harvestcircle-v1.properties").writeText(productCoordinates) @@ -470,7 +472,7 @@ class ConventionPluginSmokeTest { pluginManagement { repositories { gradlePluginPortal(); mavenCentral() } } dependencyResolutionManagement { repositories { mavenCentral() } } rootProject.name = "fixture" - include(":app:shared", ":app:desktop") + include(":app:design_system", ":app:shared", ":app:desktop") """.trimIndent() + "\n", ) fixture.resolve("gradle").createDirectories().resolve("libs.versions.toml").writeText(kmpCatalog) diff --git a/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt b/build-logic/plugins/src/main/kotlin/org/harvestcircle/buildlogic/plugins/HarvestCircleDesktopAppPlugin.kt @@ -119,6 +119,7 @@ public class HarvestCircleDesktopAppPlugin : Plugin<Project> { target: Project, catalog: org.gradle.api.artifacts.VersionCatalog, ) { + target.dependencies.add("implementation", target.project(":app:design_system")) target.dependencies.add("implementation", target.project(":app:shared")) val composeVersion = catalog.findVersion("compose").get().requiredVersion val osName = target.providers.gradleProperty("nativeOs").getOrElse(System.getProperty("os.name"))