app

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

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:
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/CanvasScaffold.kt | 5++++-
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/GlobalTopBar.kt | 29+++++++++++++++++++++++++++++
Mapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/HarvestCircleShell.kt | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------
Aapp/shared/src/commonMain/kotlin/org/harvestcircle/ui/shell/ShellAccessibility.kt | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp/shared/src/commonTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityTest.kt | 29+++++++++++++++++++++++++++++
Mapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/CanvasScaffoldTest.kt | 15+++++++++++++++
Aapp/shared/src/desktopTest/kotlin/org/harvestcircle/ui/shell/ShellAccessibilityUiTest.kt | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
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, + ) + } +}