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:
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"))