diff --git a/CHANGELOG.md b/CHANGELOG.md
index 956fc56e3..4c551bbc4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,26 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
## [Unreleased]
+## [3.9.1] - 2026-06-11
+
+### Fixed
+- **Erratic capitalization in two-thumb grace mode.** After the grace timer auto-committed a
+ word, the shift/auto-caps state wasn't refreshed, so the next word's capitalization came out
+ wrong (dropped sentence caps, or mid-word capitals). (#14)
+
+### Changed
+- **Tapped words no longer auto-finish by default** in two-thumb grace mode — the new
+ "Only auto-finish swiped words" option defaults on, so a word you tap out stays open until you
+ press space or pick a suggestion (fixes tapped shortcuts/corrections firing early). Only swiped
+ words auto-commit on a pause. (#14)
+- Reworded the two easily-confused spacing toggles: **"Only auto-space after swipes"** (the
+ trailing space) vs **"Only auto-finish swiped words"** (whether the word commits at all). (#14)
+
+### Added
+- **Experimental: defer grace-mode space** (`PREF_SPACING_DEFER_GRACE_SPACE`, default off) — routes
+ the two-thumb grace auto-commit space through the same deferred mechanism as the default swipe
+ path. (#23)
+
## [3.9.0] - 2026-06-10
### Added
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0ac2eef83..38fa7f669 100755
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -22,8 +22,8 @@ android {
applicationId = "com.asafmah.leantypedual"
minSdk = 21
targetSdk = 35
- versionCode = 3900
- versionName = "3.9.0"
+ versionCode = 3910
+ versionName = "3.9.1"
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
index b4cec9e3e..4ac354a4d 100644
--- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
+++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java
@@ -988,12 +988,21 @@ private void enterCombiningMode(final SettingsValues settingsValues, final boole
final int graceMs = baseGraceMs + Math.max(0, settingsValues.mCombiningTapExtraMs);
cancelCombiningTimerOnly();
mInCombiningMode = true;
+ // #14 "only auto-finish swiped words": still ENTER combining mode (so a following swipe
+ // can extend this word), but DON'T arm the auto-commit timer for a pure tap word — it
+ // stays open until the user commits. A tap-then-swipe still arms: the gesture re-enters
+ // here with fromTap=false and the fragment present, so it arms then.
+ final boolean armTimer = !(fromTap && settingsValues.mCombiningGraceOnlyAfterGesture
+ && !mCombiningWordHasGestureFragment && !mWordComposer.isBatchMode());
final long startTime = SystemClock.uptimeMillis();
- mPendingCombiningCommit = () -> onCombiningGraceExpired();
- mCombiningHandler.postDelayed(mPendingCombiningCommit, graceMs);
+ if (armTimer) {
+ mPendingCombiningCommit = () -> onCombiningGraceExpired();
+ mCombiningHandler.postDelayed(mPendingCombiningCommit, graceMs);
+ }
final MainKeyboardView kv = KeyboardSwitcher.getInstance().getMainKeyboardView();
if (kv != null) {
- final boolean showAutospaceIndicator = settingsValues.shouldInsertSpacesAutomatically()
+ final boolean showAutospaceIndicator = armTimer
+ && settingsValues.shouldInsertSpacesAutomatically()
&& settingsValues.mSpacingAndPunctuations.mCurrentLanguageHasSpaces
&& (!settingsValues.mCombiningAutospaceOnlyAfterGesture
|| mCombiningWordHasGestureFragment)
@@ -1200,40 +1209,54 @@ private void onCombiningGraceExpired() {
} else {
commitTyped(sv, LastComposedWord.NOT_A_SEPARATOR);
}
- // Track whether the helper actually wrote a space (skipped for URL / e-mail / phantom).
- final int beforeSpace = mConnection.getExpectedSelectionEnd();
- if (!sv.mCombiningAutospaceOnlyAfterGesture || wordHadGestureFragment) {
- insertAutomaticSpaceIfOptionsAndTextAllow(sv);
+ // #23 (PREF_SPACING_DEFER_GRACE_SPACE): defer the grace-mode space through PHANTOM
+ // instead of writing it eagerly, so it materializes on the NEXT input via the same path
+ // as the default gesture word — URL/e-mail/punctuation gates + backspace-reversibility
+ // are applied at materialization time, with no eager space to patch.
+ final boolean autospaceInserted;
+ if (sv.mSpacingDeferGraceSpace) {
+ if (!sv.mCombiningAutospaceOnlyAfterGesture || wordHadGestureFragment) {
+ // Arm the deferred space; the PHANTOM consumer (handleNonSeparatorEvent /
+ // handleSeparatorEvent) writes or suppresses it on the next input.
+ mSpaceState = SpaceState.PHANTOM;
+ } else {
+ clearOneShotSpaceActionAndNotifyIfChanged();
+ mSpaceState = SpaceState.NONE;
+ }
+ // No eager write: the cursor-delta accounting below treats this as "no space".
+ autospaceInserted = false;
+ mAutospaceJustWritten = false;
} else {
- clearOneShotSpaceActionAndNotifyIfChanged();
+ // Eager path (default). Track whether the helper actually wrote a space (skipped for
+ // URL / e-mail / phantom).
+ final int beforeSpace = mConnection.getExpectedSelectionEnd();
+ if (!sv.mCombiningAutospaceOnlyAfterGesture || wordHadGestureFragment) {
+ insertAutomaticSpaceIfOptionsAndTextAllow(sv);
+ } else {
+ clearOneShotSpaceActionAndNotifyIfChanged();
+ }
+ autospaceInserted = mConnection.getExpectedSelectionEnd() > beforeSpace;
+ // If we DID insert an autospace, fix up mLastComposedWord so revertCommit (backspace +
+ // PREF_BACKSPACE_REVERTS_AUTOCORRECT) deletes the space along with the word. Without
+ // this the revert's `deleteLength = cancelLength + separatorLength` only deletes the
+ // word, and the DEBUG assertion (last cancelLength chars equals committedWord) throws.
+ if (autospaceInserted && mLastComposedWord != null
+ && mLastComposedWord != LastComposedWord.NOT_A_COMPOSED_WORD
+ && Constants.STRING_SPACE.equals(mLastComposedWord.mSeparatorString) == false) {
+ mLastComposedWord = new LastComposedWord(
+ mLastComposedWord.mEvents,
+ mLastComposedWord.mInputPointers,
+ mLastComposedWord.mTypedWord,
+ mLastComposedWord.mCommittedWord,
+ Constants.STRING_SPACE,
+ mLastComposedWord.mNgramContext,
+ mLastComposedWord.mCapitalizedMode);
+ }
+ // Don't set PHANTOM here — we already wrote the space; PHANTOM would make the next
+ // letter insert a second one.
+ mAutospaceJustWritten = autospaceInserted;
+ mSpaceState = SpaceState.NONE;
}
- final boolean autospaceInserted = mConnection.getExpectedSelectionEnd() > beforeSpace;
- // If we DID insert an autospace, fix up mLastComposedWord so revertCommit (backspace +
- // PREF_BACKSPACE_REVERTS_AUTOCORRECT) deletes the space along with the word. Without
- // this the existing revert code's `deleteLength = cancelLength + separatorLength`
- // would only delete the word, and in DEBUG builds the bundled assertion against
- // `getTextBeforeCursor(...).subSequence(0, cancelLength) equals committedWord` throws
- // because the last cancelLength chars now include the trailing space, not the word.
- if (autospaceInserted && mLastComposedWord != null
- && mLastComposedWord != LastComposedWord.NOT_A_COMPOSED_WORD
- && Constants.STRING_SPACE.equals(mLastComposedWord.mSeparatorString) == false) {
- mLastComposedWord = new LastComposedWord(
- mLastComposedWord.mEvents,
- mLastComposedWord.mInputPointers,
- mLastComposedWord.mTypedWord,
- mLastComposedWord.mCommittedWord,
- Constants.STRING_SPACE,
- mLastComposedWord.mNgramContext,
- mLastComposedWord.mCapitalizedMode);
- }
- // Don't set PHANTOM here — we already wrote the space to the editor. PHANTOM would
- // make the next letter call insertAutomaticSpaceIfOptionsAndTextAllow AGAIN (see
- // handleNonSeparatorEvent line ~1760), giving a double space. Instead, set a
- // dedicated one-shot flag that handleSeparatorEvent uses to strip the autospace if
- // a punctuation character follows. The flag is cleared by enterCombiningMode (next
- // input took over), cancelCombiningMode (backspace etc), or once consumed.
- mAutospaceJustWritten = autospaceInserted;
- mSpaceState = SpaceState.NONE;
mConnection.endBatchEdit();
final int cursorAfter = mConnection.getExpectedSelectionEnd();
// The commit doesn't move the cursor for the composing text itself (it was already
@@ -1295,6 +1318,12 @@ private void onCombiningGraceExpired() {
mBackspaceUnits.setCommitted(writtenChars, committedFragments);
}
// "keep_alternatives" — fall through, do nothing.
+ // #14 bug fix: this commit ran on the async grace timer, OFF the normal onCodeInput path
+ // that refreshes the shift state after a commit. Without this, the next word's auto-caps
+ // is stale — auto-caps gets dropped after a grace auto-commit and capitalization comes out
+ // erratic. Mirror the gesture-commit path's requestUpdatingShiftState.
+ KeyboardSwitcher.getInstance().requestUpdatingShiftState(
+ getCurrentAutoCapsState(sv), getCurrentRecapitalizeState());
}
/**
diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
index 280eade97..c0e875464 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
+++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt
@@ -149,6 +149,8 @@ object Defaults {
const val PREF_COMBINING_AUTOCORRECT_ON_AUTOSPACE = true
const val PREF_COMBINING_TAP_EXTRA_MS = 250
const val PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE = false
+ const val PREF_SPACING_DEFER_GRACE_SPACE = false
+ const val PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE = true // default on: tapped words shouldn't auto-finish
const val PREF_COMBINING_AUTOSPACE_SUGGESTIONS = "alternatives_then_next_word"
const val PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD = true
const val PREF_COMBINING_BACKSPACE_DELETES_COMPOSING_TEXT = true
diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
index 664ea8194..cf89a5379 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
+++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java
@@ -157,6 +157,12 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang
// was a tap (peck-typists need more headroom than swipers between letters). 0 = no extra.
public static final String PREF_COMBINING_TAP_EXTRA_MS = "combining_tap_extra_ms";
public static final String PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE = "combining_autospace_only_after_gesture";
+ // #23: route the two-thumb grace-mode auto-commit space through the deferred PHANTOM
+ // mechanism (like the default gesture path) instead of writing it eagerly. Experimental.
+ public static final String PREF_SPACING_DEFER_GRACE_SPACE = "spacing_defer_grace_space";
+ // #14: when on, the combining grace timer only auto-commits words that include a swipe —
+ // pure tap-typed words are never auto-finished by the timer. Experimental, default off.
+ public static final String PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE = "combining_grace_only_after_gesture";
// What the suggestion strip shows after the combining grace timer auto-commits a word.
// Values: "keep_alternatives" (1) | "next_word" (2, default) | "alternatives_then_next_word" (3).
public static final String PREF_COMBINING_AUTOSPACE_SUGGESTIONS = "combining_autospace_suggestions";
diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
index 72a9c257c..95efd7756 100644
--- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
+++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java
@@ -137,6 +137,8 @@ public class SettingsValues {
public final boolean mCombiningAutocorrectOnAutospace;
public final int mCombiningTapExtraMs;
public final boolean mCombiningAutospaceOnlyAfterGesture;
+ public final boolean mSpacingDeferGraceSpace;
+ public final boolean mCombiningGraceOnlyAfterGesture;
// Raw string value: "keep_alternatives" | "next_word" | "alternatives_then_next_word"
public final String mCombiningAutospaceSuggestions;
public final boolean mCombiningBackspaceDeletesGestureWord;
@@ -382,6 +384,12 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina
mCombiningAutospaceOnlyAfterGesture = prefs.getBoolean(
Settings.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE,
Defaults.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE);
+ mSpacingDeferGraceSpace = prefs.getBoolean(
+ Settings.PREF_SPACING_DEFER_GRACE_SPACE,
+ Defaults.PREF_SPACING_DEFER_GRACE_SPACE);
+ mCombiningGraceOnlyAfterGesture = prefs.getBoolean(
+ Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE,
+ Defaults.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE);
mCombiningAutospaceSuggestions = prefs.getString(Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS,
Defaults.PREF_COMBINING_AUTOSPACE_SUGGESTIONS);
final boolean nonNormalTwoThumbSpacing = mGestureManualSpacing || mCombiningGraceMs > 0;
diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt
index 160a0c62e..e73389788 100644
--- a/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt
+++ b/app/src/main/java/helium314/keyboard/settings/screens/TwoThumbTypingScreen.kt
@@ -69,6 +69,8 @@ fun TwoThumbTypingScreen(
add(Settings.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE)
add(Settings.PREF_COMBINING_AUTOCORRECT_ON_AUTOSPACE)
add(Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS)
+ add(Settings.PREF_SPACING_DEFER_GRACE_SPACE)
+ add(Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE)
}
if (nonNormalSpacing) {
add(Settings.PREF_MULTIPART_FULL_WORD_SUGGESTIONS)
@@ -146,6 +148,16 @@ fun createTwoThumbTypingSettings(context: Context) = listOf(
R.string.combining_autospace_only_after_gesture_summary) {
SwitchPreference(it, Defaults.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE)
},
+ Setting(context, Settings.PREF_SPACING_DEFER_GRACE_SPACE,
+ R.string.spacing_defer_grace_space,
+ R.string.spacing_defer_grace_space_summary) {
+ SwitchPreference(it, Defaults.PREF_SPACING_DEFER_GRACE_SPACE)
+ },
+ Setting(context, Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE,
+ R.string.combining_grace_only_after_gesture,
+ R.string.combining_grace_only_after_gesture_summary) {
+ SwitchPreference(it, Defaults.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE)
+ },
Setting(context, Settings.PREF_COMBINING_AUTOSPACE_SUGGESTIONS,
R.string.combining_autospace_suggestions, R.string.combining_autospace_suggestions_summary) { def ->
val items = listOf(
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 7568f00af..82e563c1c 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -294,7 +294,15 @@
Extra autospace delay after taps
Add this much extra time when the last input was a tapped letter, so tap-then-swipe combinations are easier to continue.
Only auto-space after swipes
- When enabled, tap-only words are committed without an automatic space. Words that include a swipe still auto-space.
+ Controls the automatic space only \u2014 not whether a word commits. When on, a tap-only word still commits but without an auto-space; swiped words still get one.
+
+ Defer grace space (experimental)
+
+ Route the two-thumb grace auto-commit space through the deferred mechanism (like the default swipe path) instead of writing it immediately. The space appears on your next input and stays backspace-reversible.
+
+ Only auto-finish swiped words
+
+ The pause timer auto-commits a word only when it includes a swipe. Words you tap out stay open until you press space or pick a suggestion, so tapped shortcuts and corrections won\'t fire early. On by default \u2014 this controls whether the word commits (the auto-space option above only controls the trailing space).
Backspace deletes last swipe
diff --git a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt
index c5fdd54cc..d3f2accea 100644
--- a/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt
+++ b/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt
@@ -362,6 +362,37 @@ class InputLogicTest {
assertEquals("hello ", textBeforeCursor)
}
+ @Test fun deferredGraceSpaceMaterializesOnNextInput() {
+ // #23: with PREF_SPACING_DEFER_GRACE_SPACE on, the grace commit does NOT write the space
+ // eagerly (the default path gives "hello "); it arms PHANTOM so the space appears on the
+ // next input instead.
+ reset()
+ latinIME.prefs().edit {
+ putInt(Settings.PREF_COMBINING_GRACE_MS, 1000)
+ putBoolean(Settings.PREF_SPACING_DEFER_GRACE_SPACE, true)
+ }
+ gestureInput("hello")
+ expireCombiningGrace()
+ assertEquals("hello", textBeforeCursor) // deferred: no trailing space yet
+ chainInput("world")
+ assertEquals("hello world", textBeforeCursor) // materialized on the next letter
+ }
+
+ @Test fun deferredGraceCommitIsBackspaceReversible() {
+ // The deferred commit leaves no eager space to orphan; the first backspace deletes the
+ // gesture word cleanly (PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD default on).
+ reset()
+ latinIME.prefs().edit {
+ putInt(Settings.PREF_COMBINING_GRACE_MS, 1000)
+ putBoolean(Settings.PREF_SPACING_DEFER_GRACE_SPACE, true)
+ }
+ gestureInput("hello")
+ expireCombiningGrace()
+ assertEquals("hello", textBeforeCursor)
+ functionalKeyPress(KeyCode.DELETE)
+ assertEquals("", textBeforeCursor)
+ }
+
@Test fun tapThenGestureCombiningWordStillAutospacesWhenGestureGateEnabled() {
reset()
latinIME.prefs().edit {
diff --git a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt
index a51cfb673..30451afb7 100644
--- a/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt
+++ b/app/src/test/java/helium314/keyboard/settings/SettingsContainerTest.kt
@@ -60,6 +60,18 @@ class SettingsContainerTest {
container[Settings.PREF_COMBINING_AUTOSPACE_ONLY_AFTER_GESTURE]?.key)
}
+ @Test
+ fun spacingDeferGraceSpaceSettingIsRegistered() {
+ assertEquals(Settings.PREF_SPACING_DEFER_GRACE_SPACE,
+ container[Settings.PREF_SPACING_DEFER_GRACE_SPACE]?.key)
+ }
+
+ @Test
+ fun combiningGraceOnlyAfterGestureSettingIsRegistered() {
+ assertEquals(Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE,
+ container[Settings.PREF_COMBINING_GRACE_ONLY_AFTER_GESTURE]?.key)
+ }
+
@Test
fun twoThumbLowLevelBackspaceSettingIsHiddenFromSearchRegistry() {
assertNull(container[Settings.PREF_COMBINING_BACKSPACE_DELETES_GESTURE_WORD])
diff --git a/docs/SPACING_POLICY.md b/docs/SPACING_POLICY.md
new file mode 100644
index 000000000..534e0f177
--- /dev/null
+++ b/docs/SPACING_POLICY.md
@@ -0,0 +1,114 @@
+# Spacing Policy
+
+**Status:** design spec. Epic [#14]; implementation tracked in [#23] (B6b), [#24] (B6a), [#25] (B6c/C1), [#26] (tuning panel).
+
+Goal: rethink autospace/grace as a **per-word spacing *policy*** driven by word-state signals already computed every keystroke — not a fixed timer — with an opt-in "Assisted" tier, decoupled commit-from-space, and adaptive per-posture cadence. Feel-driven: every knob is a live, on-device-tunable experimental setting; **never hardcode a feel decision**.
+
+---
+
+## 1. The problem
+
+Two different mechanisms do the same job inconsistently, and grace is blind to whether a word is *finished*:
+
+- **Default gesture path** uses **`SpaceState.PHANTOM`**: the space is *deferred* and materialized on the next input, so it adapts to connectors / URLs / punctuation and stays backspace-reversible.
+- **Combining-grace path** writes the space **eagerly** in `onCombiningGraceExpired` (`InputLogic.java` ~1139–1156: `insertAutomaticSpaceIfOptionsAndTextAllow(sv)` + `mAutospaceJustWritten`).
+- Grace is a **fixed timer** (`mCombiningGraceMs`). It can't tell a finished word ("I") from an extendable stem, so finished words still need a space tap while stems get committed too eagerly.
+
+## 2. Principles
+
+- **Signals over timers.** Decisions key off per-keystroke word state, at **zero extra native cost**.
+- **One deferred-space mechanism** (PHANTOM) for every commit path.
+- **Opt-in / default-off**, conservative defaults.
+- **Backspace-reversible**, and the policy **never fires mid multipart / live-converge fragment**.
+- **Feel-driven**: knobs are live experimental settings (§7); no hardcoded feel calls; validate on-device.
+
+## 3. Current state (the seam)
+
+- `SpaceState` values: `NONE`, `DOUBLE`, `SWAP_PUNCTUATION`, `WEAK`, `PHANTOM` (`inputlogic/SpaceState.java`). PHANTOM = the deferred-space promise consumed on next input.
+- `OneShotSpaceAction` (`inputlogic/OneShotSpaceAction.kt`): `JOIN_NEXT`, `FORCE_NEXT_SPACE`.
+- PHANTOM consumers that materialize/suppress the space on next input: `handleNonSeparatorEvent` (~373 / ~2003), `handleSeparatorEvent` (~2207).
+- The combining grace timer: scheduled in `enterCombiningMode`, cleared in `cancelCombiningMode`, fires `onCombiningGraceExpired` (~1139–1156), which currently commits **and eagerly spaces**.
+- Backspace-revert of a committed word reads `mLastComposedWord.mSeparatorString` (patched ~1148–1156).
+
+## 4. Signals (free — from `mSuggestedWords`, computed every keystroke)
+
+```
+complete = mTypedWordValid && mTypedWordInfo.mSourceDict != DICTIONARY_USER_TYPED
+prefixRichScore = (# of KIND_COMPLETION candidates) / (total candidates) // normalized [0..1]
+graceMs = clamp(base − completeBonus·complete + prefixPenalty·prefixRichScore, min, max)
+```
+
+- `complete`: the typed word is a real dictionary word (not a user-typed-only string).
+- **`prefixRichScore`** is a **normalized score** (completions ÷ total), not a raw count — more stable across dictionaries of different sizes. High = lots of longer words start with this stem (keep open); low = little left to extend to (safe to commit).
+
+## 5. The "Assisted" tier — two gates
+
+A confident, complete word auto-commits with the space **deferred** (PHANTOM). It fires through **either** gate, mapping onto the two signals:
+
+| Gate | Condition | Behaviour |
+|---|---|---|
+| **A — instant** | `complete` **and** `prefixRichScore ≤ lowThreshold` | Commit immediately (nothing plausible left to extend to: "I", "the", a finished gesture). Space deferred. |
+| **B — pause** | `complete` **and** `prefixRichScore > lowThreshold` | Hold; commit only after an inter-word **pause** (the adaptive threshold, §6) — the stem is extendable, give the user time. Space deferred. |
+| _neither_ | not `complete` | Stay open; fall back to the signal-driven `graceMs` timer. |
+
+So finished words feel instant; extendable-but-complete words wait until you stop. Subsumes the old "short-word auto-commit" idea, but dictionary-driven.
+
+**Guards (hard):** only above the confidence floor; **never** while a multipart / live-converge fragment is mid-flight; the deferred space keeps the whole thing backspace-reversible.
+
+## 6. Adaptive per-posture cadence ([#25])
+
+The Gate-B pause threshold is **learned**, not fixed: a running percentile of the user's real inter-word pause distribution. **Separate baselines per posture** (one-handed / two-handed), keyed off the existing one-handed toggle (`KeyboardSwitcher.setOneHandedModeEnabled` ~535–547, today layout-only — timing is greenfield) so switching posture **loads the stored baseline instantly** instead of slowly re-adapting. Far-key reach stays handled by the existing layout shrink + swipe shortcuts (C2), not here.
+
+## 7. Tuning & insight ([#26])
+
+- **For users:** a small set of **discrete tiers** — `Off` / `Conservative` / `Assisted` / `Aggressive` — that pick coherent knob bundles.
+- **Behind an "experimental" expander:** the raw **sliders** — `base` grace, `completeBonus`, `prefixPenalty`, `lowThreshold`, Gate-B pause percentile — for on-device tuning.
+- **Live:** changes take effect with **no restart**; defaults conservative; the whole policy **default-off**.
+- **Fuller A11 typing-insight overlay** (paired): per word, surface *why* it committed or stayed open — `complete`, `prefixRichScore`, the resolved `graceMs`, and **which gate fired** (A / B / none). This is the feedback loop that makes the sliders tunable by feel.
+
+## 8. Sequencing
+
+| Phase | Issue | What | Why here |
+|---|---|---|---|
+| 1 | [#23] | Route the grace commit through PHANTOM (remove the eager write) | Structurally correct regardless of feel knobs — the foundation. |
+| 2 | [#26] | Live tuning-panel infra + the fuller A11 insight overlay | Force-multiplier: makes Phase 3 tunable on-device and shows *why*. |
+| 3 | [#24] | Signal-driven `graceMs` + the two-gate Assisted tier | The meat; tuned via Phase 2 + on-device playtest. |
+| 4 | [#25] | Adaptive per-posture cadence | The learning layer on top. |
+
+(#23 is "first" per the epic; #26 is slotted **second** because Phase 3 is feel-critical and un-tunable without it.)
+
+## 9. Phase 1 (#23) implementation notes — small seam, deliberate test rewrite
+
+- **Seam:** in `onCombiningGraceExpired`, replace `insertAutomaticSpaceIfOptionsAndTextAllow(sv)` + `mAutospaceJustWritten` with `mSpaceState = SpaceState.PHANTOM`. Existing PHANTOM consumers then materialize/suppress on next input (already solved for the default path).
+- **Move** the `mLastComposedWord.mSeparatorString` backspace-revert patch (~1148–1156) to **space-materialization time**.
+- **Test-contract rewrite (not optional):** ~8 `InputLogicTest` cases lock in the *eager* space — `expireCombiningGrace(); assertEquals("hello ", textBeforeCursor)` at ~259/276/357/372/461. Each must be rewritten to assert **deferred-then-materialized** (`"hello"` after expiry, `"hello "` only after the next letter/separator). Add explicit commit-then-backspace and commit-then-punctuation coverage.
+- **Risk:** this changes locked-in behaviour contracts. Build it **behind the experimental flag** and validate with **on-device playtesting** — not a blind autonomous change.
+
+## 10. Settings (new keys, all experimental / default-off)
+
+Follow the 5-file pattern (`Settings.java` / `Defaults.kt` / `SettingsValues.java` / `strings.xml` / a settings screen) + a `SettingsContainerTest` case each.
+
+| Key (proposed) | Type | Meaning |
+|---|---|---|
+| `PREF_SPACING_POLICY_TIER` | enum | Off / Conservative / Assisted / Aggressive |
+| `PREF_SPACING_GRACE_BASE_MS` | int | `base` in the graceMs formula |
+| `PREF_SPACING_COMPLETE_BONUS_MS` | int | `completeBonus` |
+| `PREF_SPACING_PREFIX_PENALTY_MS` | int | `prefixPenalty` |
+| `PREF_SPACING_LOW_THRESHOLD` | float | Gate-A `lowThreshold` on `prefixRichScore` |
+| `PREF_SPACING_PAUSE_PERCENTILE` | int | Gate-B adaptive-pause percentile |
+
+(Tier selection writes a coherent bundle into the sliders; the experimental expander exposes the sliders directly. Reuse `MultiSliderPreference.kt`.)
+
+## 11. Testing strategy
+
+- **Unit-testable (JVM):** the `graceMs` formula and gate selection are deterministic given a `mSuggestedWords` snapshot — table-test them directly. The Phase 1 deferred-space contract is covered by the rewritten grace tests + new backspace-revert cases.
+- **On-device only (feel):** the actual cadence, the tier defaults, and the adaptive percentile. These ride the #26 panel + the A11 overlay; do **not** assert specific timings in tests.
+- The native gesture-replay harness ([#78]) and the trace recorder ([#20]) provide fixtures for regression-checking that the policy doesn't break recognition.
+
+[#14]: https://github.com/AsafMah/LeanType/issues/14
+[#20]: https://github.com/AsafMah/LeanType/issues/20
+[#23]: https://github.com/AsafMah/LeanType/issues/23
+[#24]: https://github.com/AsafMah/LeanType/issues/24
+[#25]: https://github.com/AsafMah/LeanType/issues/25
+[#26]: https://github.com/AsafMah/LeanType/issues/26
+[#78]: https://github.com/AsafMah/LeanType/issues/78
diff --git a/fastlane/metadata/android/en-US/changelogs/3910.txt b/fastlane/metadata/android/en-US/changelogs/3910.txt
new file mode 100644
index 000000000..4f97d6f82
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/3910.txt
@@ -0,0 +1,4 @@
+- Two-thumb: fixed capitalization going wrong after the grace timer auto-finishes a word (dropped sentence capitals / stray mid-word capitals)
+- Two-thumb: words you tap out no longer auto-finish on a pause by default — they stay open until you press space or pick a suggestion, so tapped shortcuts and corrections don't fire early (only swiped words auto-finish). Toggle under Two-thumb settings.
+- Clearer labels for the two spacing options ("Only auto-space after swipes" vs "Only auto-finish swiped words")
+- Experimental (off by default): defer the two-thumb grace space like the default swipe path