fix(ui): address below-cut UI/Compose review nits (#308) #437

Merged
JMR-dev merged 2 commits from refactor-308-ui-nits into main 2026-07-08 17:24:42 +00:00
JMR-dev commented 2026-07-08 13:08:22 +00:00 (Migrated from github.com)

Closes #308.

Addresses six of the seven below-cut LOW review nits collected in #308; the seventh is deliberately skipped with a rationale (below). Each behavioral change ships a JVM/Robolectric test; PII-free AppLog breadcrumbs were added on the new fallback / state-change paths.

Per-nit outcome

# Nit Outcome
1 SettingsViewModel app-lock disable runs Keystore sealWithMaster() on Main Fixed — reseal now runs in withContext(defaultDispatcher) (default Dispatchers.Default), mirroring AppLockViewModel's threading policy. Behavior-preserving.
2 AppLockGateHost leaves app content in the semantics tree behind the lock cover (TalkBack can traverse occluded nodes) Fixed — the covered subtree is clearAndSetSemantics {}'d while Checking/Locked (the repo's established a11y-gating idiom). Content stays composed so its state still survives a re-lock.
3 ReaderViewModel.toggleStar optimistic update never reconciled on failure Fixed — on a failed setStarred, the optimistic flip is rolled back and a one-shot ReaderEvent.StarFailed is surfaced (snackbar).
4 OnboardingViewModel.firstAddedAccountId not in SavedStateHandle → process-kill lands on unfiltered mailbox Fixed — stored in SavedStateHandle (nav-graph entry state survives process death), preserving #30.
5 RichTextEditor toolbar re-parses whole body + ~10 scans per keystroke Fixed — the parse + all selection scans are derived once via remember(value) into a ToolbarState. Pure refactor (identical values).
6 AccountSetupViewModel surfaces a normal OAuth cancel as an error snackbar Fixed — a null result (RESULT_CANCELED) is now a no-op (breadcrumb only), not an error.
7 MailboxViewModel search re-pages the local list per keystroke (documented intentional) Skipped — see below.

Why #7 is skipped

The finding is explicitly "documented intentional" and phrased as a "could". The current design is deliberate and good: the local Room pager narrows already-cached results instantly as the user types (responsive), while the expensive server search is already debounced at 400 ms (SEARCH_DEBOUNCE_MS). Debouncing the local pager would add typing lag to local narrowing for no clear benefit, and doing it correctly (debouncing only the query dimension, not account/folder switches) adds real complexity/risk to a hot, well-tested cachedIn path also driven by the keep-alive presenter (#219). Per the perf drilldown, the mailbox's real cost is server-side IMAP throttling, which is already debounced. Disproportionate to apply blind.

Tests

  • SettingsViewModelTest — new test proves the disable-path reseal is dispatched off-main; existing branch tests pin behavior (dispatcher pinned to the scheduler).
  • AppLockGateHostJvmTest (Robolectric Compose) — rewrote the re-lock test to prove content stays composed (DisposableEffect counter) and is gated out of the a11y tree while locked, then re-displayed on unlock.
  • ReaderViewModelActionsTest — new rollback + StarFailed test.
  • OnboardingViewModelTest — new process-death survival test (restored SavedStateHandle).
  • RichTextEditorTest — new toolbarStateOf one-pass derivation test.
  • AccountSetupViewModelTest — cancel test updated to assert the no-op.

Gate

Local fast gate green: assembleDebug, testDebugUnitTest (1452 tests), jacocoTestCoverageVerification (floor 0.84), compileDebugAndroidTestKotlin, lintDebug, ktlintCheck, detekt. E2E via CI matrix.

Closes #308. Addresses six of the seven below-cut LOW review nits collected in #308; the seventh is deliberately skipped with a rationale (below). Each behavioral change ships a JVM/Robolectric test; PII-free `AppLog` breadcrumbs were added on the new fallback / state-change paths. ## Per-nit outcome | # | Nit | Outcome | |---|-----|---------| | 1 | `SettingsViewModel` app-lock disable runs Keystore `sealWithMaster()` on Main | **Fixed** — reseal now runs in `withContext(defaultDispatcher)` (default `Dispatchers.Default`), mirroring `AppLockViewModel`'s threading policy. Behavior-preserving. | | 2 | `AppLockGateHost` leaves app content in the semantics tree behind the lock cover (TalkBack can traverse occluded nodes) | **Fixed** — the covered subtree is `clearAndSetSemantics {}`'d while Checking/Locked (the repo's established a11y-gating idiom). Content stays composed so its state still survives a re-lock. | | 3 | `ReaderViewModel.toggleStar` optimistic update never reconciled on failure | **Fixed** — on a failed `setStarred`, the optimistic flip is rolled back and a one-shot `ReaderEvent.StarFailed` is surfaced (snackbar). | | 4 | `OnboardingViewModel.firstAddedAccountId` not in `SavedStateHandle` → process-kill lands on unfiltered mailbox | **Fixed** — stored in `SavedStateHandle` (nav-graph entry state survives process death), preserving #30. | | 5 | `RichTextEditor` toolbar re-parses whole body + ~10 scans per keystroke | **Fixed** — the parse + all selection scans are derived once via `remember(value)` into a `ToolbarState`. Pure refactor (identical values). | | 6 | `AccountSetupViewModel` surfaces a normal OAuth cancel as an error snackbar | **Fixed** — a null result (RESULT_CANCELED) is now a no-op (breadcrumb only), not an error. | | 7 | `MailboxViewModel` search re-pages the local list per keystroke (documented intentional) | **Skipped** — see below. | ### Why #7 is skipped The finding is explicitly *"documented intentional"* and phrased as a *"could"*. The current design is deliberate and good: the local Room pager narrows already-cached results **instantly** as the user types (responsive), while the expensive **server** search is already debounced at 400 ms (`SEARCH_DEBOUNCE_MS`). Debouncing the local pager would add typing lag to local narrowing for no clear benefit, and doing it correctly (debouncing only the query dimension, not account/folder switches) adds real complexity/risk to a hot, well-tested `cachedIn` path also driven by the keep-alive presenter (#219). Per the perf drilldown, the mailbox's real cost is server-side IMAP throttling, which is already debounced. Disproportionate to apply blind. ## Tests - `SettingsViewModelTest` — new test proves the disable-path reseal is dispatched off-main; existing branch tests pin behavior (dispatcher pinned to the scheduler). - `AppLockGateHostJvmTest` (Robolectric Compose) — rewrote the re-lock test to prove content stays composed (DisposableEffect counter) **and** is gated out of the a11y tree while locked, then re-displayed on unlock. - `ReaderViewModelActionsTest` — new rollback + `StarFailed` test. - `OnboardingViewModelTest` — new process-death survival test (restored `SavedStateHandle`). - `RichTextEditorTest` — new `toolbarStateOf` one-pass derivation test. - `AccountSetupViewModelTest` — cancel test updated to assert the no-op. ## Gate Local fast gate green: `assembleDebug`, `testDebugUnitTest` (1452 tests), `jacocoTestCoverageVerification` (floor 0.84), `compileDebugAndroidTestKotlin`, `lintDebug`, `ktlintCheck`, `detekt`. E2E via CI matrix.
mergify[bot] commented 2026-07-08 16:12:31 +00:00 (Migrated from github.com)

Merge Queue Status

This pull request spent 1 hour 12 minutes 20 seconds in the queue, including 18 minutes 59 seconds running CI.

Required conditions to merge
<!--- DO NOT EDIT -*- Mergify Payload -*- {"version": 1, "state": "merged", "queue_rule_name": "default", "queued_at": "2026-07-08T16:12:25.935768+00:00", "estimated_time_of_merge": null, "speculative_check_pr": null, "required_conditions": []} -*- Mergify Payload End -*- --> # Merge Queue Status - ✅ **Entered queue** — `2026-07-08 16:12 UTC` · Rule: `default` · triggered by merge protections - ✅ **Checks passed** · on draft #452 - ✅ **Merged** — `2026-07-08 17:24 UTC` · at `91f2f7105b31016394ece5c0434a3dcbf73bcc87` · merge This pull request spent **1 hour 12 minutes 20 seconds** in the queue, including **18 minutes 59 seconds** running CI. <details> <summary>Required conditions to merge</summary> - `-conflict` - [X] #437 - [X] #446 - `-draft` - [X] #437 - [X] #446 - [X] `base = main` - [X] `check-success = CI passed` - `github-review-approved` [🛡 GitHub repository ruleset rule `main`] - [X] #437 - [X] #446 - `label != broken` - [X] #437 - [X] #446 - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Debug build` - [ ] `check-neutral = Debug build` - [ ] `check-skipped = Debug build` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = Unit tests` - [ ] `check-neutral = Unit tests` - [ ] `check-skipped = Unit tests` - [X] any of [🛡 GitHub branch protection]: - [X] `check-success = CI passed` - [ ] `check-neutral = CI passed` - [ ] `check-skipped = CI passed` - [X] any of [🛡 GitHub repository ruleset rule `main`]: - [X] `check-success = @github-actions/CI passed` - [ ] `check-neutral = @github-actions/CI passed` - [ ] `check-skipped = @github-actions/CI passed` </details>
Sign in to join this conversation.