commit 23b2cddadac96bba15992fe6332bbc4c73865ec5
parent 859dd17ed338c318aa0442e7759454741d034bbd
Author: triesap <tyson@radroots.org>
Date: Tue, 11 Aug 2026 17:06:30 +0000
accessibility: implement shell keyboard and focus
- Dispatch the locked desktop shortcuts through typed shell intents.
- Restore route focus and give shared controls labelled 44dp focus targets.
- Apply session text scaling, large-text canvas scrolling, and reduced-motion policy.
- Verify keyboard, focus, sizing, formatting, Rust, and desktop test suites.
Diffstat:
7 files changed, 309 insertions(+), 34 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
@@ -27,6 +27,9 @@ fun CanvasScaffold(
body: @Composable () -> Unit,
actionBar: @Composable () -> Unit,
) {
+ val effectiveTextSize =
+ textSize.takeUnless { it == TextSizePreference.Default }
+ ?: LocalShellAppearance.current.textSize
Column(Modifier.fillMaxSize().testTag("canvas-scaffold")) {
Row(
Modifier
@@ -45,7 +48,7 @@ fun CanvasScaffold(
.weight(1f)
.fillMaxWidth()
.let { modifier ->
- if (canvasBodyScroll(textSize) == ScrollOwnership.CanvasBodyAccessibility) {
+ if (canvasBodyScroll(effectiveTextSize) == ScrollOwnership.CanvasBodyAccessibility) {
modifier.verticalScroll(rememberScrollState())
} else {
modifier
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt
@@ -1,12 +1,21 @@
package org.harvestcircle.ui.shell
+import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
@@ -14,6 +23,9 @@ import androidx.compose.ui.semantics.disabled
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
+import org.harvestcircle.design.ColorToken
+import org.harvestcircle.design.HarvestCircleDesign
+import org.harvestcircle.design.ThemePreference
enum class SyncStatusLabel(
val text: String,
@@ -86,10 +98,22 @@ internal fun ShellAction(
enabled: Boolean = true,
onClick: () -> Unit,
) {
+ var focused by remember { mutableStateOf(false) }
+ val focusColor =
+ when (LocalShellAppearance.current.theme) {
+ ThemePreference.Dark -> HarvestCircleDesign.dark.focus
+ ThemePreference.System, ThemePreference.Light -> HarvestCircleDesign.light.focus
+ }.composeColor()
BasicText(
text = label,
modifier =
Modifier
+ .heightIn(min = HarvestCircleDesign.MINIMUM_TARGET_DP.dp)
+ .onFocusChanged { focused = it.isFocused }
+ .border(
+ width = HarvestCircleDesign.BORDER_DP.dp,
+ color = if (focused) focusColor else Color.Transparent,
+ ).padding(horizontal = HarvestCircleDesign.spacingDp[2].dp)
.semantics {
contentDescription = description
role = Role.Button
@@ -98,3 +122,8 @@ internal fun ShellAction(
.testTag(tag),
)
}
+
+private fun ColorToken.composeColor(): Color {
+ val rgb = hex.removePrefix("#").toLong(16)
+ return Color(0xFF000000 or rgb)
+}
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
@@ -5,8 +5,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import org.harvestcircle.application.FoundationOverlay
import org.harvestcircle.application.HarvestCircleIntent
@@ -31,6 +35,28 @@ fun HarvestCircleShell(
platformActions: HarvestCirclePlatformActions,
dispatch: (HarvestCircleShellIntent) -> Unit,
) {
+ val baseDensity = LocalDensity.current
+ val scaledDensity =
+ remember(baseDensity, state.appearance.textSize) {
+ Density(baseDensity.density, baseDensity.fontScale * state.appearance.textSize.scale)
+ }
+ CompositionLocalProvider(
+ LocalDensity provides scaledDensity,
+ LocalShellAppearance provides state.appearance,
+ ) {
+ ShellKeyboardHost(onShortcut = { dispatchShortcut(it, dispatch) }) {
+ HarvestCircleShellContent(state, identityActions, platformActions, dispatch)
+ }
+ }
+}
+
+@Composable
+private fun HarvestCircleShellContent(
+ state: HarvestCircleShellState,
+ identityActions: HarvestCircleUiActions,
+ platformActions: HarvestCirclePlatformActions,
+ dispatch: (HarvestCircleShellIntent) -> Unit,
+) {
when (val root = state.root) {
is ShellRoot.LifecycleCanvas ->
HarvestCircleScreen(state.identity.toUiModel(), identityActions, platformActions)
@@ -150,45 +176,64 @@ private fun DashboardRoot(
sidebar = { WorkspaceSidebar(destination) { dispatch(HarvestCircleShellIntent.Navigate(it)) } },
mainHeader = { MainPanelHeader(MainPanelHeaderModel(title = route.title())) },
mainBody = {
- when (route) {
- AppRoute.PersonalToday ->
- FoundationTodayScreen(
- model = FoundationTodayModel(todayContext(state)),
- openNostrReference = {
- dispatch(
- HarvestCircleShellIntent.Overlay(
- OverlayIntent.Open(FoundationOverlay.OpenNostrReference()),
+ RouteFocusTarget(route.toString(), "${route.title()} main content") {
+ when (route) {
+ AppRoute.PersonalToday ->
+ FoundationTodayScreen(
+ model = FoundationTodayModel(todayContext(state)),
+ openNostrReference = {
+ dispatch(
+ HarvestCircleShellIntent.Overlay(
+ OverlayIntent.Open(FoundationOverlay.OpenNostrReference()),
+ ),
+ )
+ },
+ )
+ AppRoute.Network -> FoundationNetworkScreen(foundationNetworkModel(state))
+ is AppRoute.Settings ->
+ FoundationSettingsScreen(
+ section = route.section,
+ appearance = state.appearance,
+ buildInfo = state.buildInfo,
+ actions =
+ FoundationSettingsActions(
+ selectSection = {
+ dispatch(
+ HarvestCircleShellIntent.Navigation(
+ NavigationIntent.Navigate(AppRoute.Settings(it)),
+ ),
+ )
+ },
+ setTheme = { dispatch(HarvestCircleShellIntent.SetTheme(it)) },
+ setTextSize = { dispatch(HarvestCircleShellIntent.SetTextSize(it)) },
+ setMotion = { dispatch(HarvestCircleShellIntent.SetMotion(it)) },
),
- )
- },
- )
- AppRoute.Network -> FoundationNetworkScreen(foundationNetworkModel(state))
- is AppRoute.Settings ->
- FoundationSettingsScreen(
- section = route.section,
- appearance = state.appearance,
- buildInfo = state.buildInfo,
- actions =
- FoundationSettingsActions(
- selectSection = {
- dispatch(
- HarvestCircleShellIntent.Navigation(
- NavigationIntent.Navigate(AppRoute.Settings(it)),
- ),
- )
- },
- setTheme = { dispatch(HarvestCircleShellIntent.SetTheme(it)) },
- setTextSize = { dispatch(HarvestCircleShellIntent.SetTextSize(it)) },
- setMotion = { dispatch(HarvestCircleShellIntent.SetMotion(it)) },
- ),
- platformActions = platformActions,
- )
- else -> BasicText(route.title(), Modifier.testTag("foundation-route-body"))
+ platformActions = platformActions,
+ )
+ else -> BasicText(route.title(), Modifier.testTag("foundation-route-body"))
+ }
}
},
)
}
+private fun dispatchShortcut(
+ shortcut: ShellShortcut,
+ dispatch: (HarvestCircleShellIntent) -> Unit,
+) {
+ val intent =
+ when (shortcut) {
+ ShellShortcut.Back -> HarvestCircleShellIntent.Navigation(NavigationIntent.Back)
+ ShellShortcut.Forward -> HarvestCircleShellIntent.Navigation(NavigationIntent.Forward)
+ ShellShortcut.OpenNostrReference ->
+ HarvestCircleShellIntent.Overlay(OverlayIntent.Open(FoundationOverlay.OpenNostrReference()))
+ ShellShortcut.Today -> HarvestCircleShellIntent.Navigate(ShellDestination.Today)
+ ShellShortcut.Settings -> HarvestCircleShellIntent.Navigate(ShellDestination.Settings)
+ ShellShortcut.CloseOverlay -> HarvestCircleShellIntent.Overlay(OverlayIntent.Escape)
+ }
+ dispatch(intent)
+}
+
private fun todayContext(state: HarvestCircleShellState): String =
when {
state.session.readOnly -> "Read-only session"
diff --git a/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellAccessibility.kt b/app/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellAccessibility.kt
@@ -0,0 +1,103 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.isAltPressed
+import androidx.compose.ui.input.key.isCtrlPressed
+import androidx.compose.ui.input.key.isMetaPressed
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import org.harvestcircle.design.AppearanceState
+import org.harvestcircle.design.MotionPreference
+
+val LocalShellAppearance = compositionLocalOf { AppearanceState() }
+
+enum class ShellShortcutKey { Left, Right, K, One, Comma, Escape }
+
+enum class ShellShortcut { Back, Forward, OpenNostrReference, Today, Settings, CloseOverlay }
+
+fun resolveShellShortcut(
+ key: ShellShortcutKey,
+ alt: Boolean = false,
+ controlOrMeta: Boolean = false,
+): ShellShortcut? =
+ when {
+ alt && key == ShellShortcutKey.Left -> ShellShortcut.Back
+ alt && key == ShellShortcutKey.Right -> ShellShortcut.Forward
+ controlOrMeta && key == ShellShortcutKey.K -> ShellShortcut.OpenNostrReference
+ controlOrMeta && key == ShellShortcutKey.One -> ShellShortcut.Today
+ controlOrMeta && key == ShellShortcutKey.Comma -> ShellShortcut.Settings
+ !alt && !controlOrMeta && key == ShellShortcutKey.Escape -> ShellShortcut.CloseOverlay
+ else -> null
+ }
+
+fun nonessentialMotionEnabled(appearance: AppearanceState): Boolean = appearance.motion != MotionPreference.Reduced
+
+@Composable
+fun ShellKeyboardHost(
+ onShortcut: (ShellShortcut) -> Unit,
+ content: @Composable () -> Unit,
+) {
+ Box(
+ Modifier
+ .fillMaxSize()
+ .onPreviewKeyEvent { event ->
+ event.toShellShortcut()?.let {
+ onShortcut(it)
+ true
+ } ?: false
+ }.testTag("shell-keyboard-host"),
+ ) {
+ content()
+ }
+}
+
+@Composable
+fun RouteFocusTarget(
+ routeKey: String,
+ label: String,
+ content: @Composable () -> Unit,
+) {
+ val requester = remember(routeKey) { FocusRequester() }
+ Box(
+ Modifier
+ .fillMaxSize()
+ .focusRequester(requester)
+ .focusable()
+ .semantics { contentDescription = label }
+ .testTag("route-focus-target"),
+ ) {
+ content()
+ }
+ LaunchedEffect(routeKey) { requester.requestFocus() }
+}
+
+private fun KeyEvent.toShellShortcut(): ShellShortcut? {
+ if (type != KeyEventType.KeyDown) return null
+ val shellKey =
+ when (key) {
+ Key.DirectionLeft -> ShellShortcutKey.Left
+ Key.DirectionRight -> ShellShortcutKey.Right
+ Key.K -> ShellShortcutKey.K
+ Key.One -> ShellShortcutKey.One
+ Key.Comma -> ShellShortcutKey.Comma
+ Key.Escape -> ShellShortcutKey.Escape
+ else -> return null
+ }
+ return resolveShellShortcut(shellKey, alt = isAltPressed, controlOrMeta = isCtrlPressed || isMetaPressed)
+}
diff --git a/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityTest.kt b/app/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityTest.kt
@@ -0,0 +1,29 @@
+package org.harvestcircle.ui.shell
+
+import org.harvestcircle.design.AppearanceState
+import org.harvestcircle.design.MotionPreference
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class ShellAccessibilityTest {
+ @Test
+ fun resolvesOnlyTheLockedEnabledShortcuts() {
+ assertEquals(ShellShortcut.Back, resolveShellShortcut(ShellShortcutKey.Left, alt = true))
+ assertEquals(ShellShortcut.Forward, resolveShellShortcut(ShellShortcutKey.Right, alt = true))
+ assertEquals(ShellShortcut.OpenNostrReference, resolveShellShortcut(ShellShortcutKey.K, controlOrMeta = true))
+ assertEquals(ShellShortcut.Today, resolveShellShortcut(ShellShortcutKey.One, controlOrMeta = true))
+ assertEquals(ShellShortcut.Settings, resolveShellShortcut(ShellShortcutKey.Comma, controlOrMeta = true))
+ assertEquals(ShellShortcut.CloseOverlay, resolveShellShortcut(ShellShortcutKey.Escape))
+ assertNull(resolveShellShortcut(ShellShortcutKey.K))
+ assertNull(resolveShellShortcut(ShellShortcutKey.Comma, alt = true))
+ }
+
+ @Test
+ fun reducedMotionDisablesNonessentialTransitions() {
+ assertTrue(nonessentialMotionEnabled(AppearanceState()))
+ assertFalse(nonessentialMotionEnabled(AppearanceState(motion = MotionPreference.Reduced)))
+ }
+}
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
@@ -1,5 +1,6 @@
package org.harvestcircle.ui.shell
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.semantics.SemanticsActions
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertCountEquals
@@ -7,6 +8,7 @@ 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.AppearanceState
import org.harvestcircle.design.TextSizePreference
import kotlin.test.Test
import kotlin.test.assertFalse
@@ -32,6 +34,19 @@ class CanvasScaffoldTest {
assertTrue(onNodeWithTag("canvas-body").fetchSemanticsNode().config.contains(SemanticsActions.ScrollBy))
onNodeWithTag("canvas-action-bar").assertIsDisplayed()
}
+
+ @Test
+ fun shellAppearanceEnablesLargeTextFallbackForDefaultCanvases() =
+ runComposeUiTest {
+ setContent {
+ CompositionLocalProvider(
+ LocalShellAppearance provides AppearanceState(textSize = TextSizePreference.VeryLarge),
+ ) {
+ canvas(TextSizePreference.Default)
+ }
+ }
+ assertTrue(onNodeWithTag("canvas-body").fetchSemanticsNode().config.contains(SemanticsActions.ScrollBy))
+ }
}
@androidx.compose.runtime.Composable
diff --git a/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt b/app/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt
@@ -0,0 +1,51 @@
+package org.harvestcircle.ui.shell
+
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertHeightIsAtLeast
+import androidx.compose.ui.test.assertIsFocused
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performKeyInput
+import androidx.compose.ui.test.v2.runComposeUiTest
+import androidx.compose.ui.unit.dp
+import kotlin.test.Test
+
+@OptIn(ExperimentalTestApi::class)
+class ShellAccessibilityUiTest {
+ @Test
+ fun sharedActionsMeetTheMinimumTargetAndRouteContentRestoresFocus() =
+ runComposeUiTest {
+ setContent {
+ RouteFocusTarget("today", "Today main content") {
+ ShellAction("Action", "Accessible action", "accessible-action", onClick = {})
+ }
+ }
+
+ onNodeWithTag("accessible-action").assertHeightIsAtLeast(44.dp)
+ onNodeWithTag("route-focus-target").assertIsFocused()
+ }
+
+ @Test
+ fun keyboardHostDispatchesModifiedAndEscapeShortcuts() =
+ runComposeUiTest {
+ val shortcuts = mutableListOf<ShellShortcut>()
+ setContent {
+ ShellKeyboardHost(shortcuts::add) {
+ RouteFocusTarget("today", "Today main content", content = {})
+ }
+ }
+
+ onNodeWithTag("route-focus-target").performKeyInput {
+ keyDown(Key.CtrlLeft)
+ keyDown(Key.K)
+ keyUp(Key.K)
+ keyUp(Key.CtrlLeft)
+ keyDown(Key.Escape)
+ keyUp(Key.Escape)
+ }
+ kotlin.test.assertEquals(
+ listOf(ShellShortcut.OpenNostrReference, ShellShortcut.CloseOverlay),
+ shortcuts,
+ )
+ }
+}