feat(keyboard): in-keyboard onboarding overlay + AppContext chip

Two long-standing UX papercuts, fixed without leaving the keyboard:

1. **First-launch onboarding inside the keyboard** — iOS keyboard
   extensions *cannot* programmatically switch back to the previous
   app after jumping out, so the old flow (jump to host app → user
   has to manually navigate back) was 5+ taps of friction. The new
   `KeyboardOnboardingOverlay` keeps the user inside the keyboard
   for steps 1 (welcome), 2 (mic permission), 3 (speech permission),
   and 5 (API key hint). The only step that *must* leave is step 4
   ("Enable Keyboard"), which jumps to `Settings.app` via
   `UIApplication.openSettingsURLString` — on return,
   `viewWillAppear` calls `autoAdvancePastKeyboardSetupStepIfNeeded`
   which silently advances past that step if the keyboard is now
   enabled. Net UX: user types in their app, keyboard walks them
   through setup, normal UI appears as soon as setup is done.

2. **Per-app context chip on the keyboard top bar** — the v0.3.0
   intelligent-prompt pipeline already adapted tone to detected
   context (code/email/chat/document), but without a UI cue the user
   had no way to know which mode was active or override the heuristic
   when it guessed wrong. The new `AppContextChip` surfaces the
   detected context; tap-to-override writes back to
   `AppGroupStore.setDetectedAppContext` so the next LLM call
   picks up the new tone immediately. Wired into `pressBegan` so
   the chip updates in real time as the user types into different
   fields.

### Files added
- `OSGKeyboardExt/Views/AppContextChip.swift` — chip + Menu override
- `OSGKeyboardExt/Views/KeyboardOnboardingOverlay.swift` — 5-step overlay
- `OSGKeyboardTests/KeyboardOnboardingOverlayTests.swift` — round-trip + enum surface tests

### Files modified
- `OSGKeyboardShared/Services/KeyboardState.swift`
  + `hasCompletedOnboarding`, `onboardingPage`, `appContext`
  + `setAppContext`, `advanceOnboarding`, `completeOnboarding`
  + `requestMicPermission`, `requestSpeechPermission`, `openSystemSettings`
- `OSGKeyboardShared/Services/AppGroupStore.swift`
  + `hasCompletedOnboarding` / `onboardingPage` accessors (mirror of
    `ProviderConfig` keys, so the keyboard extension never has to
    instantiate the host-app config)
- `OSGKeyboardExt/KeyboardViewController.swift`
  + action hooks wired (`installStateActions`)
  + `syncOnboardingStateFromAppGroup` / `syncAppContextFromAppGroup`
    called on `viewWillAppear` and `loadPersistedConfig`
  + `autoAdvancePastKeyboardSetupStepIfNeeded` for the silent
    "jump out → come back" flow
  + `openSystemSettingsFromExtension` opens `Settings.app` via
    `UIApplication.openSettingsURLString` (the only system URL
    the extension is allowed to open)
  + `detectAndStoreAppContext` mirrors to `state.appContext` so
    the chip updates without waiting for `viewWillAppear`
- `OSGKeyboardExt/Views/KeyboardRootView.swift`
  + overlay mounted in `ZStack` over normal UI (animated)
  + AppContextChip in top bar (hidden during onboarding)
- `OSGKeyboardExt/{en,zh-Hans}.lproj/Keyboard.strings`
  + onboarding copy + chip labels (35 keys per language)

### iOS sandbox notes (kept here for posterity)
- Keyboard extensions **cannot** present AVAudioSession /
  SFSpeechRecognizer permission dialogs directly. The overlay's
  step 2/3 buttons optimistically advance; the actual permission
  is granted when the user first opens the host app (which the
  step-5 "Open OSGKeyboard" button triggers). This is the same
  pattern the previous "jump to host app" flow used — just
  without the broken return trip.
- `UIApplication.openSettingsURLString` is the only system URL
  reachable from `extensionContext.open`. Both step 4 and the
  "Open Settings" button route through `HostAppLauncher` so the
  responder-chain fallback also kicks in if needed.

Co-authored-by: Mavis <Mavis@hkgood.dev>
This commit is contained in:
Mavis
2026-07-03 07:55:12 +00:00
parent 7dc37e4272
commit 0401f67a89
9 changed files with 760 additions and 16 deletions
+35
View File
@@ -202,3 +202,38 @@
"locale.en-US" = "English (US)";
"locale.ja-JP" = "Japanese";
"locale.ko-KR" = "Korean";
/* v0.3.0: in-keyboard first-launch onboarding */
"keyboard.onboarding.title" = "Set up OSGKeyboard";
"keyboard.onboarding.back" = "Back";
"keyboard.onboarding.getStarted" = "Get Started";
"keyboard.onboarding.welcome.title" = "Welcome to OSGKeyboard";
"keyboard.onboarding.welcome.body" = "Voice-to-text with AI polish. Set up takes 30 seconds — most of it happens right here in the keyboard.";
"keyboard.onboarding.mic.title" = "Microphone access";
"keyboard.onboarding.mic.body" = "OSGKeyboard needs microphone access to transcribe your speech. iOS will show a system prompt.";
"keyboard.onboarding.mic.grant" = "Allow Microphone";
"keyboard.onboarding.speech.title" = "Speech recognition";
"keyboard.onboarding.speech.body" = "Apple's on-device speech engine turns your voice into text. iOS will show a system prompt.";
"keyboard.onboarding.speech.grant" = "Allow Speech Recognition";
"keyboard.onboarding.keyboard.title" = "Enable OSGKeyboard";
"keyboard.onboarding.keyboard.body" = "Open Settings → General → Keyboard → Keyboards → Add New Keyboard → OSGKeyboard. Then tap OSGKeyboard again and enable Allow Full Access. Come back here when you're done.";
"keyboard.onboarding.keyboard.openSettings" = "Open Settings";
"keyboard.onboarding.api.title" = "One last step";
"keyboard.onboarding.api.body" = "To polish your text with AI, OSGKeyboard needs an LLM API key. You can add it now in the app, or skip and add it later from Settings.";
"keyboard.onboarding.api.skipHint" = "Polishing won't work without an API key, but the keyboard will still type the raw transcript.";
"keyboard.onboarding.api.skip" = "Skip";
"keyboard.onboarding.api.openHostApp" = "Open OSGKeyboard";
/* v0.3.0: AppContext chip on the keyboard top bar */
"keyboard.appContext.a11y" = "Polish context";
"keyboard.appContext.a11yHint" = "Tap to override the auto-detected input context (code, email, chat, document).";
"keyboard.appContext.chip.code" = "Code";
"keyboard.appContext.chip.email" = "Email";
"keyboard.appContext.chip.chat" = "Chat";
"keyboard.appContext.chip.document" = "Document";
"keyboard.appContext.chip.unknown" = "General";
"keyboard.appContext.menu.code" = "Code — preserve identifiers, no natural-language wrap";
"keyboard.appContext.menu.email" = "Email — polite, professional, paragraph-broken";
"keyboard.appContext.menu.chat" = "Chat — short, casual, emoji-friendly";
"keyboard.appContext.menu.document" = "Document — long-form, structured";
"keyboard.appContext.menu.unknown" = "General — neutral tone";