app

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

commit bec8462a0ab530659aa068e76369fcac21e6a470
parent 8b5aaf5f7a084a25fd8fd1f7731b89f7922bb35c
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 00:03:54 +0000

ui: harden account surface accessibility

- remove nested clickable account row controls
- add explicit semantic selection and activation actions
- focus masked import input deterministically
- keep long profiles recoveries and account registries scrollable

Diffstat:
Mapp/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt | 22++++++++++++++++++++--
Mapp/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt | 23++++++++++++++++++++++-
2 files changed, 42 insertions(+), 3 deletions(-)

diff --git a/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt b/app/desktop/src/main/kotlin/org/radroots/studio/accounts/ui/StudioScreen.kt @@ -10,10 +10,16 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.Role @@ -61,6 +67,7 @@ fun StartupFailureScreen(problem: String) { .fillMaxSize() .background(WindowBackgroundColor) .padding(24.dp) + .verticalScroll(rememberScrollState()) .testTag("startup-failure"), verticalArrangement = Arrangement.spacedBy(16.dp), ) { @@ -144,6 +151,7 @@ private fun ActiveAccountHome( .fillMaxSize() .background(WindowBackgroundColor) .padding(24.dp) + .verticalScroll(rememberScrollState()) .testTag("home-screen"), verticalArrangement = Arrangement.spacedBy(10.dp), ) { @@ -153,7 +161,7 @@ private fun ActiveAccountHome( BasicText(active.account.publicKeyHex, Modifier.testTag("active-pubkey-hex")) BasicText("Name: ${active.profile.name}", Modifier.testTag("active-profile-name")) BasicText("Display name: ${active.profile.displayName}") - BasicText("NIP-05: ${active.profile.nip05}") + BasicText("NIP-05 (unverified): ${active.profile.nip05}") BasicText("About: ${active.profile.about}", Modifier.testTag("active-profile-about")) BasicText("Picture: ${active.profile.picture}") BasicText("Relay: ${active.relayState}", Modifier.testTag("relay-state")) @@ -284,6 +292,8 @@ private fun AccountEntry(model: StudioUiModel, actions: StudioUiActions) { ) } AccountEntryMode.IMPORT -> { + val importFocusRequester = remember { FocusRequester() } + LaunchedEffect(Unit) { importFocusRequester.requestFocus() } TextAction( text = "Back", testTag = "cancel-account-entry", @@ -302,6 +312,7 @@ private fun AccountEntry(model: StudioUiModel, actions: StudioUiActions) { contentDescription = "Nostr secret key" password() } + .focusRequester(importFocusRequester) .testTag("import-nsec-input") .background(InputBackgroundColor) .padding(8.dp), @@ -342,7 +353,6 @@ private fun ColumnScope.SavedAccountList( .fillMaxWidth() .semantics { selected = account.selected } .testTag("account-row:${account.publicKeyHex}") - .clickable { actions.selectAccount(account.publicKeyHex) } .background(InputBackgroundColor) .padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -353,6 +363,13 @@ private fun ColumnScope.SavedAccountList( if (account.selected) BasicText("Selected") if (account.active) BasicText("Active") TextAction( + text = if (account.selected) "Selected account" else "Select", + testTag = "select-account:${account.publicKeyHex}", + contentDescription = "Select ${account.label}", + enabled = !model.busy && !account.selected, + onClick = { actions.selectAccount(account.publicKeyHex) }, + ) + TextAction( text = if (account.active) "Active account" else "Activate", testTag = "activate-account:${account.publicKeyHex}", contentDescription = "Activate ${account.label}", @@ -403,6 +420,7 @@ private fun GeneratedKeyRecoveryScreen( .fillMaxSize() .background(WindowBackgroundColor) .padding(24.dp) + .verticalScroll(rememberScrollState()) .testTag("generated-key-backup"), verticalArrangement = Arrangement.spacedBy(8.dp), ) { diff --git a/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt b/app/desktop/src/test/kotlin/org/radroots/studio/accounts/ui/StudioScreenTest.kt @@ -7,12 +7,15 @@ import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.v2.runComposeUiTest import kotlin.test.Test import kotlin.test.assertEquals @@ -81,6 +84,7 @@ class StudioScreenTest { onNodeWithTag("generate-key").performClick() onNodeWithTag("cancel-account-entry").performClick() onNodeWithTag("choose-import-account").performClick() + onNodeWithTag("import-nsec-input").assertIsFocused() onNodeWithTag("import-nsec-input").performTextInput("nsec1secret") onNodeWithTag("import-key").performClick() @@ -185,7 +189,7 @@ class StudioScreenTest { onNodeWithTag("saved-account-list").assertIsDisplayed() onNodeWithTag("account-row:${first.publicKeyHex}").assertIsSelected() - onNodeWithTag("account-row:${second.publicKeyHex}").performClick() + onNodeWithTag("select-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() onNodeWithTag("activate-account:${second.publicKeyHex}", useUnmergedTree = true).performClick() assertEquals(listOf(second.publicKeyHex), selected) assertEquals(listOf(second.publicKeyHex), activated) @@ -201,6 +205,23 @@ class StudioScreenTest { } @Test + fun savedAccountListRemainsReachableForLargeRegistries() = runComposeUiTest { + val accounts = (0 until 100).map { index -> + accountUi(index.toString(16).padStart(64, '0'), selected = index == 0) + } + setContent { + StudioScreen( + model = emptyUiModel().copy(accounts = accounts), + actions = StudioUiActions(), + ) + } + + val lastTag = "account-row:${accounts.last().publicKeyHex}" + onNodeWithTag("saved-account-list").performScrollToNode(hasTestTag(lastTag)) + onNodeWithTag(lastTag).assertIsDisplayed() + } + + @Test fun activeHomeShowsIdentityProfileRelayAndCommands() = runComposeUiTest { var refreshCalls = 0 var signOutCalls = 0