commit 6eb5b7b534360955432904a24cba0904ce648166
parent 643337eb6af242892777fb9f4b00da642f811b18
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 16:25:16 +0000
ui: implement the canvas scaffold
- fix the canvas header and action regions
- center the bounded nine-hundred-sixty-dp body
- expose navigation and workflow-step slots
- enable body scrolling only for very large text
Diffstat:
2 files changed, 115 insertions(+), 0 deletions(-)
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/CanvasScaffold.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/CanvasScaffold.kt
@@ -0,0 +1,68 @@
+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.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.dp
+import org.harvestcircle.design.TextSizePreference
+
+@Composable
+fun CanvasScaffold(
+ textSize: TextSizePreference,
+ navigation: @Composable () -> Unit = {},
+ header: @Composable () -> Unit,
+ step: @Composable () -> Unit = {},
+ body: @Composable () -> Unit,
+ actionBar: @Composable () -> Unit,
+) {
+ Column(Modifier.fillMaxSize().testTag("canvas-scaffold")) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .height(64.dp)
+ .semantics { contentDescription = "Canvas header" }
+ .testTag("canvas-header"),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(Modifier.testTag("canvas-navigation")) { navigation() }
+ Box(Modifier.weight(1f)) { header() }
+ Box(Modifier.testTag("canvas-step")) { step() }
+ }
+ val bodyModifier =
+ Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .let { modifier ->
+ if (canvasBodyScroll(textSize) == ScrollOwnership.CanvasBodyAccessibility) {
+ modifier.verticalScroll(rememberScrollState())
+ } else {
+ modifier
+ }
+ }.semantics { contentDescription = "Canvas body" }
+ .testTag("canvas-body")
+ Box(bodyModifier, contentAlignment = Alignment.TopCenter) {
+ Box(Modifier.fillMaxWidth().widthIn(max = 960.dp)) { body() }
+ }
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .height(72.dp)
+ .semantics { contentDescription = "Canvas action bar" }
+ .testTag("canvas-action-bar"),
+ ) {
+ actionBar()
+ }
+ }
+}
diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/CanvasScaffoldTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/CanvasScaffoldTest.kt
@@ -0,0 +1,47 @@
+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.assertIsDisplayed
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.v2.runComposeUiTest
+import org.harvestcircle.design.TextSizePreference
+import kotlin.test.Test
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalTestApi::class)
+class CanvasScaffoldTest {
+ @Test
+ fun baselineCanvasKeepsHeaderBodyAndActionRegionsFixed() =
+ runComposeUiTest {
+ setContent { canvas(TextSizePreference.Default) }
+ onNodeWithTag("canvas-header").assertIsDisplayed()
+ onNodeWithTag("canvas-body").assertIsDisplayed()
+ onNodeWithTag("canvas-action-bar").assertIsDisplayed()
+ onAllNodesWithTag("canvas-navigation").assertCountEquals(1)
+ assertFalse(onNodeWithTag("canvas-body").fetchSemanticsNode().config.contains(SemanticsActions.ScrollBy))
+ }
+
+ @Test
+ fun veryLargeTextEnablesOnlyBoundedBodyScrolling() =
+ runComposeUiTest {
+ setContent { canvas(TextSizePreference.VeryLarge) }
+ assertTrue(onNodeWithTag("canvas-body").fetchSemanticsNode().config.contains(SemanticsActions.ScrollBy))
+ onNodeWithTag("canvas-action-bar").assertIsDisplayed()
+ }
+}
+
+@androidx.compose.runtime.Composable
+private fun canvas(textSize: TextSizePreference) {
+ CanvasScaffold(
+ textSize = textSize,
+ navigation = {},
+ header = {},
+ step = {},
+ body = {},
+ actionBar = {},
+ )
+}