Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions docs/mobile-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,14 @@ wait for the editor, privacy and testing foundation.

## Now

- **Checkout:** `android-keyboard-hardening`, branch `codex/android-alpha21-release-record`.
- **Checkout:** `android-keyboard-hardening`, branch `codex/android-comfort-pass`.
- **Released:** [signed alpha21](https://github.com/RioPlay/utterleaf/releases/tag/android-v0.1.0-alpha21), including the clean-room daily-surface reset and live selected-text completion safeguard (PRs #64–#66).
- **Next:** complete the workflow-only Node.js/action and runner-image maintenance recorded in
[alpha20 release readiness](plans/completed/android-alpha20-release-readiness.md#post-release-ci-maintenance-memory).
- **Current:** finish review and exact-main validation of the bounded
[daily-keyboard comfort pass](plans/active/android-comfort-pass.md). It quiets
suggestions, improves hint hierarchy, guarantees bottom-row touch widths and
unifies the action-key shape without changing typing, privacy or editor logic.
- **Next:** complete the workflow-only Node.js/action and runner-image maintenance
recorded in [alpha20 release readiness](plans/completed/android-alpha20-release-readiness.md#post-release-ci-maintenance-memory).
Editor-bound dictation recovery and the remaining Tools architecture work stay
in the [capability and experience plan](plans/active/android-experience-refresh.md).
Android remains the priority; desktop is parked.
Expand All @@ -50,6 +54,18 @@ wait for the editor, privacy and testing foundation.

## Current status

- **Daily-keyboard comfort candidate verified locally — September 27, 2026:**
suggestions now use quiet, borderless fixed slots and clear stale hidden text;
empty states add no filler. Secondary hints use the palette's muted color at
9 sp, ordinary keys have explicit pressed fills, and the dynamic action uses
the standard key radius. Fixed mode/punctuation/action widths guarantee at
least 48 dp bottom-row targets while Space absorbs the remainder. Android
tooling passed **26/26**, JVM/lint passed, and the focused emulator bundle
passed **58/58** after exact reruns documented in the
[comfort plan](plans/active/android-comfort-pass.md#status--september-27-2026).
Final empty/one/three-candidate renders were inspected. No physical-device or
live assistive-technology claim is made.

- **Alpha21 signed prerelease published — September 27, 2026:** PR #66 merged
release metadata as exact main revision
`394fdff11b8ed300d320d9e28c04876d00cc2c93`. Exact-main Android run
Expand Down
97 changes: 97 additions & 0 deletions docs/plans/active/android-comfort-pass.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Android daily-keyboard comfort pass

## Goal

Make ordinary Utterleaf typing feel calmer and more comfortable while retaining
the independently implemented interaction model released in alpha21.

## Area

- `mobile/android/app/src/main/java/org/utterleaf/voice/TypingPanel.kt`
- `mobile/android/app/src/main/java/org/utterleaf/voice/Ui.kt`
- focused daily-strip and keyboard-geometry instrumentation tests
- Android roadmap and this plan

## Constraints

- Preserve editor behavior, privacy policy, suggestion computation, gestures,
key ordering, preference identifiers and specialist Tools behavior.
- Keep the suggestion region fixed at 48 dp and every actionable daily control
at least 48 dp in both orientations.
- Do not copy FUTO code, assets, layouts, labels, constants or theme values.
FUTO is a behavioral comfort benchmark only; Utterleaf's implementation and
visual tokens remain independent.
- Emulator rendering is not evidence of physical-phone comfort.

## Acceptance

- Suggestions read as quiet inline text choices rather than a second set of
raised keys, while retaining three predictable tap regions.
- An empty suggestion state is visually calm and does not add instructional
filler or change keyboard geometry.
- Secondary hints remain legible but are subordinate to primary key labels.
- The dynamic action key uses the same rounded-key language as the keyboard;
the branded dictation control remains visually distinct.
- Pressed, focused, selected and disabled controls retain visible state.
- Portrait, landscape, one-handed, split and sensitive layouts retain their
existing geometry and behavior contracts.

## Verification

1. `python -m unittest discover -s mobile/android/tools -p test_*.py`
2. `mobile/android/gradlew.bat testDebugUnitTest lintDebug --no-daemon`
3. Focused emulator classes: `DailyToolbarContractTest`,
`KeyboardSpacingTest`, `KeyboardGeometryTest`, `KeyboardTuningTest`,
`SuggestionStripTest`, `SensitiveTypingPanelTest`, `OneHandLayoutTest`,
`SplitKeyboardTest`, `CompactLayerTest` and `KeyboardEditorContractTest`.
4. Render ordinary empty, one-candidate and three-candidate states for visual
comparison. Record the emulator-only limitation.

## Non-goals

- No new prediction, autocorrection, gesture typing or language behavior.
- No Tools architecture, settings, dictation-state or editor-lifecycle changes.
- No release/version bump in this slice.
- No claim of literal visual parity with another keyboard.

## Stop

Stop when the bounded daily-surface changes and focused checks pass. Move any
interaction or architecture issue discovered during review to the owning plan
instead of expanding this pass.

## Status — September 27, 2026

Implemented on `codex/android-comfort-pass`:

- Added a dedicated flat suggestion role with stateful pressed/focus feedback,
three stable slots and no empty-state filler.
- Hidden slots now clear candidate text and accessibility descriptions instead
of retaining stale words in invisible views.
- Centralized the bounded key radii, hint size and bottom-row widths in
`Ui.KeyboardTokens`; hints use the opaque palette-muted color at 9 sp.
- Mode, comma, Space, period and action now retain at least 48 dp touch width;
Space absorbs remaining width. The action keeps an explicit accent role and
uses the standard key radius. Dictation remains the branded pill.
- Added explicit pressed fills without changing gesture routing or animation.
- Added a contrast-safe inset focus/selected outline for primary controls; the
action remains visually distinct under external-keyboard focus.

Verification:

- Android tooling: **26/26 passed**.
- JVM tests and `lintDebug`: **passed**; lint reported no build-stopping issue.
- Focused emulator bundle: **58/58 passed** across daily toolbar, spacing,
geometry, tuning, suggestions, sensitive mode, one-hand, split and compact
layers, plus the real-editor action contract. Earlier runs exposed one newly
over-strict one-pixel width assertion, test assumptions that did not account
for split mode's two Space keys, and one default-IME readiness race. Review
also found that primary focus initially matched the default state. The test
assumptions were corrected, the readiness case passed on exact rerun, the
focus outline and rendering assertion were added, and the final combined run
was clean.
- Final emulator renders for empty, no-match, one- and three-candidate states
were inspected locally under `.grok/validation/comfort-final-2/`.

This is emulator evidence only. Physical-phone comfort and live assistive-
technology review remain unverified.
Original file line number Diff line number Diff line change
Expand Up @@ -88,18 +88,28 @@ class KeyboardSpacingTest {
@Test fun suggestionStripKeepsKeyGeometryAcrossCandidateStates() = main {
var state = SuggestionEngine.SuggestionState.EMPTY
val panel = panel(state = { state })
fun dailyControlBounds() = listOf("Keyboard tools", "Editing tools", "Dictate")
.associateWith { bounds(panel, it) }
panel.reset(false, false, "Enter")
val emptyHeight = measure(panel)
val emptySpace = bounds(panel, "Space")
val emptyQ = bounds(panel, "q")
val emptyControls = dailyControlBounds()
val emptySuggestionSlots = descendants(panel.view).filterIsInstance<HintedKey>()
.filter { it.visualRole == KeyVisualRole.SUGGESTION }
assertEquals("Daily strip must retain three predictable suggestion slots",
3, emptySuggestionSlots.size)
assertTrue("Empty suggestion slots must stay quiet without collapsing",
emptySuggestionSlots.all { it.visibility == View.INVISIBLE && it.measuredWidth > 0 })
capture(panel, "empty")

state = SuggestionEngine.SuggestionState("q", emptyList())
panel.refreshSuggestions()
val noMatchHeight = measure(panel)
val noMatchSpace = bounds(panel, "Space")
val noMatchQ = bounds(panel, "q")
assertTrue("No-match state must be represented accessibly",
val noMatchControls = dailyControlBounds()
assertFalse("No-match state must not add instructional filler",
descendants(panel.view).filterIsInstance<TextView>().any { it.text == "No completions" })
capture(panel, "no-match")

Expand All @@ -108,6 +118,7 @@ class KeyboardSpacingTest {
val oneHeight = measure(panel)
val oneSpace = bounds(panel, "Space")
val oneQ = bounds(panel, "q")
val oneControls = dailyControlBounds()
val oneChipCount = descendants(panel.view).filterIsInstance<Button>()
.count { it.contentDescription == "Complete with quick" }
capture(panel, "one")
Expand All @@ -117,6 +128,7 @@ class KeyboardSpacingTest {
val threeHeight = measure(panel)
val threeSpace = bounds(panel, "Space")
val threeQ = bounds(panel, "q")
val threeControls = dailyControlBounds()
val threeChipCount = descendants(panel.view).filterIsInstance<Button>()
.count { it.contentDescription?.toString()?.startsWith("Complete with ") == true }
capture(panel, "three")
Expand All @@ -129,14 +141,50 @@ class KeyboardSpacingTest {
assertEquals("Three completions moved Space", oneSpace, threeSpace)
assertEquals("Expected three completion chips", 3, threeChipCount)
assertEquals("Expected the current candidate set to contain quick", 1, oneChipCount)
val visibleSuggestionSlots = descendants(panel.view).filterIsInstance<HintedKey>()
.filter { it.visualRole == KeyVisualRole.SUGGESTION && it.visibility == View.VISIBLE }
assertEquals("Expected three visible suggestion slots", 3, visibleSuggestionSlots.size)
val suggestionWidths = visibleSuggestionSlots.map { it.width }
assertTrue("Suggestion slots must divide their region evenly",
suggestionWidths.max() - suggestionWidths.min() <= 1)
assertTrue("Suggestion slots must retain 48dp touch widths",
visibleSuggestionSlots.all { it.width >= Ui.dp(app, 48) })
assertTrue("Completions must use the quiet suggestion visual role",
visibleSuggestionSlots.all { it.background.isStateful })
fun assertPressedDiffers(label: String, button: Button) {
val normal = android.graphics.Bitmap.createBitmap(button.width, button.height,
android.graphics.Bitmap.Config.ARGB_8888)
val pressed = android.graphics.Bitmap.createBitmap(button.width, button.height,
android.graphics.Bitmap.Config.ARGB_8888)
try {
button.isPressed = false; button.jumpDrawablesToCurrentState()
button.draw(android.graphics.Canvas(normal))
button.isPressed = true; button.jumpDrawablesToCurrentState()
button.draw(android.graphics.Canvas(pressed))
assertFalse("$label pressed state was visually indistinguishable", normal.sameAs(pressed))
} finally {
button.isPressed = false; button.jumpDrawablesToCurrentState()
normal.recycle(); pressed.recycle()
}
}
assertPressedDiffers("Suggestion", visibleSuggestionSlots.first())
assertPressedDiffers("Letter key", key(panel, "q"))
assertEquals("No-match moved q", emptyQ, noMatchQ)
assertEquals("One completion moved q", noMatchQ, oneQ)
assertEquals("Three completions moved q", oneQ, threeQ)
assertEquals("No-match moved daily controls", emptyControls, noMatchControls)
assertEquals("One completion moved daily controls", noMatchControls, oneControls)
assertEquals("Three completions moved daily controls", oneControls, threeControls)
state = SuggestionEngine.SuggestionState.EMPTY
key(panel, "Space").performClick()
val afterSpaceHeight = measure(panel)
assertEquals("Space must not change the strip geometry", threeHeight, afterSpaceHeight)
assertTrue("Empty strip must be represented accessibly",
val clearedSlots = descendants(panel.view).filterIsInstance<HintedKey>()
.filter { it.visualRole == KeyVisualRole.SUGGESTION }
assertTrue("Hidden suggestions must not retain stale candidate text",
clearedSlots.all { it.visibility == View.INVISIBLE && !it.isEnabled &&
it.text.isEmpty() && it.contentDescription.isNullOrEmpty() })
assertFalse("Empty strip must not add instructional filler",
descendants(panel.view).filterIsInstance<TextView>().any { it.text == "Type a word" })
capture(panel, "after-space")
}
Expand Down Expand Up @@ -169,36 +217,83 @@ class KeyboardSpacingTest {

@Test fun bottomRowHasNoUnassignedTouchRegionOutsideExplicitSplitChannel() = main {
val configurations = listOf(
Configuration.ORIENTATION_PORTRAIT to 360,
Configuration.ORIENTATION_LANDSCAPE to 700,
Triple(Configuration.ORIENTATION_PORTRAIT, 320, false),
Triple(Configuration.ORIENTATION_LANDSCAPE, 600, false),
Triple(Configuration.ORIENTATION_LANDSCAPE, 600, true),
)
for ((orientation, widthDp) in configurations) {
for ((orientation, widthDp, split) in configurations) {
val configured = Configuration(app.resources.configuration).apply {
this.orientation = orientation
}
val panelContext = app.createConfigurationContext(configured)
for (alignment in KeyboardAlignment.entries) {
val alignments = if (split) listOf(KeyboardAlignment.FULL) else KeyboardAlignment.entries
for (alignment in alignments) {
val panel = panel(panelContext,
KeyboardOptions(numberRow = false, alignment = alignment), enabled = false)
KeyboardOptions(numberRow = false, alignment = alignment,
splitLandscape = split), enabled = false)
panel.reset(false, false, "Enter")
val width = Ui.dp(panelContext, widthDp)
panel.view.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED))
panel.view.layout(0, 0, width, panel.view.measuredHeight)

val bottom = key(panel, "Space").parent as LinearLayout
val bottom = descendants(panel.view).filterIsInstance<Button>()
.first { it.contentDescription == "Space" }.parent as LinearLayout
val children = (0 until bottom.childCount).map { bottom.getChildAt(it) }
assertTrue("$orientation/$alignment bottom row contains an inert slot",
children.all { it is Button && it.isClickable && it.isFocusable })
if (split) {
assertEquals("Split bottom row must contain exactly one intentional center channel",
1, children.count { it !is Button })
assertTrue("Split bottom row contains another inert slot",
children.filterIsInstance<Button>().all { it.isClickable && it.isFocusable })
} else {
assertTrue("$orientation/$alignment bottom row contains an inert slot",
children.all { it is Button && it.isClickable && it.isFocusable })
}
assertEquals(0, children.first().left)
assertEquals(bottom.width, children.last().right)
children.zipWithNext().forEach { (left, right) ->
assertEquals("$orientation/$alignment bottom row has a dead gap",
assertEquals("$orientation/$alignment/split=$split bottom row has a dead gap",
left.right, right.left)
}
assertTrue(descendants(panel.view).filterIsInstance<Button>()
.mapNotNull { it.contentDescription?.toString() }
.containsAll(setOf(",", "Space", ".")))
listOf("Switch letters and symbols", ",", "Space", ".", "Enter").forEach { description ->
val targets = descendants(panel.view).filterIsInstance<Button>()
.filter { it.contentDescription == description }
assertTrue("$orientation/$alignment/split=$split $description was absent",
targets.isNotEmpty())
assertTrue("$orientation/$alignment/split=$split $description was narrower than 48dp",
targets.all { it.width >= Ui.dp(panelContext, 48) })
assertTrue("$orientation/$alignment/split=$split $description was shorter than 48dp",
targets.all { it.height >= Ui.dp(panelContext, 48) })
}
val action = key(panel, "Enter")
assertTrue("$orientation/$alignment/split=$split action key was disabled", action.isEnabled)
assertEquals(KeyVisualRole.ACTION, (action as HintedKey).visualRole)
val bitmap = android.graphics.Bitmap.createBitmap(action.width, action.height,
android.graphics.Bitmap.Config.ARGB_8888)
val focused = android.graphics.Bitmap.createBitmap(action.width, action.height,
android.graphics.Bitmap.Config.ARGB_8888)
try {
action.background.draw(android.graphics.Canvas(bitmap))
assertTrue("$orientation/$alignment/split=$split action key lost its primary fill",
bitmap.getPixel(action.width / 2, action.height / 2) !=
Ui.palette(panelContext, KeyboardOptions(numberRow = false,
alignment = alignment, splitLandscape = split)).background)
bitmap.eraseColor(android.graphics.Color.TRANSPARENT)
action.draw(android.graphics.Canvas(bitmap))
action.isFocusableInTouchMode = true
assertTrue("$orientation/$alignment/split=$split action did not accept focus",
action.requestFocus())
action.jumpDrawablesToCurrentState()
action.draw(android.graphics.Canvas(focused))
assertFalse("$orientation/$alignment/split=$split action focus was visually indistinguishable",
bitmap.sameAs(focused))
} finally {
action.clearFocus(); action.jumpDrawablesToCurrentState()
bitmap.recycle(); focused.recycle()
}
panel.dispose()
}
}
Expand Down
Loading
Loading