Commit Graph

18 Commits

Author SHA1 Message Date
rocky 93b6aa6c02 feat(translation-polish): dual-engine translation UX + provider picker filter + topbar cleanup
- Add LLMProvider.isUserSelectable (default true) and filter
  ProviderPickerSection on it; next pass can hide non-user presets
  (e.g. a future DeepSeek key-preset) without changing call sites.
- KeyboardRootView: hide TranslationChip when off (matches user's
  mental model of an opt-in feature), drop the 'warming' branch
  (Qwen3 download UX was removed with the backend in v0.2.0), unify
  chip pill height to minHeight 28 + vertical 6 for visual rhythm
  across all topbar chips.
- TranslationChip: drop isLocal warning path — both engines now
  run the translate-and-polish step (local routes through DeepSeek
  via ProviderConfig.localModeProviderId).
- OnboardingView: cloud engine branch now wraps the translation
  row in the same surface card chrome as the local branch.
- Strings: drop keyboard.models.warming (no longer referenced).

DeepSeek key pre-fill deferred to a follow-up.
2026-06-25 15:16:33 +08:00
rocky deddb49d56 feat(translation): add post-ASR translation mode for cloud engine
Adds an opt-in translation pipeline that reuses the existing
PolishingService + LLMClient + AppGroupStore chain. Translation is
implemented as a new PolishMode (.translate(targetLocaleId:)); all
existing call sites are unchanged.

Settings:
- New TranslationPickerRow in the language tab (Toggle + 10-locale
  picker: en/zh-Hans/zh-Hant/ja/ko/fr/de/es/ru/pt), persisted to the
  App Group so the keyboard extension can read it during live dictation.
- 5 new strings per language (en + zh-Hans).

Keyboard:
- New TranslationChip on the top bar to the right of LocaleChip;
  same Menu pattern, lets users toggle or quickly switch target
  language without leaving the keyboard.
- PolishingService dispatches .translate with a parameterised prompt
  (en/zh variants selected by provider id); PolishingService.error
  gains a translationNotAvailable case so local-engine users get a
  clear inline warning when the toggle is on but cloud is off.
- 6 new strings per language (en + zh-Hans) for the chip + banner.

Local engine policy:
- Translation is cloud-only by design (local engine stays ASR-only
  to honour the no-roundtrip promise). Chip shows a 'cloud required'
  state and raw transcript still inserts on failure — no data loss.

Build:
- OSGKeyboardShared adds TranslationLanguage enum (10 locales) and
  TranslationPrompt factory.
- 4 new files, 9 modified. xcodebuild scheme=OSGKeyboard
  config=Debug destination=iPhone 17 Simulator: BUILD SUCCEEDED
  (0 warning, 0 error).

Also pins DEVELOPMENT_TEAM in project.yml for TestFlight uploads
(3 targets; Team X329MZU23S).
2026-06-25 12:45:44 +08:00
Rocky df1c5ff32c feat: migrate on-device Qwen3 ASR to CoreML for background Flow dictation
Replace MLX GPU inference with CoreML bundles so transcription continues
while the host app is backgrounded. Adds model download and warm-up,
vendored Qwen3Speech, and updates onboarding, settings, and copy for the
~1.6 GB CoreML package (iOS 18+).
2026-06-23 00:46:58 +08:00
Rocky 642c97c540 fix: privacy manifests, history/settings headers, and dock contrast
Resolve TestFlight ITMS-91056 by correcting privacy manifest keys and reason arrays. Add left-aligned page headers with circular confirm buttons, keep the keyboard surface transparent, and improve unselected dock icon contrast in light and dark mode.
2026-06-20 14:10:18 +08:00
Rocky db5151ad9a feat: flow session reliability, keyboard UX, and audio pre-roll fix
Renew voice sessions while the host app stays foreground, auto-start flow from the keyboard with a Start action, and prevent leading audio loss via pre-roll buffering and faster signal polling.
2026-06-20 00:57:16 +08:00
Rocky 38a36ed504 feat: tab shell, home/onboarding UI, settings and keyboard polish
Introduce MainTabView with history and Liquid Glass dock; refresh home and
onboarding layouts; unify accent green and provider localization; refine
settings/API rows; redesign keyboard mic and flanking controls; set Utility
iPhone-only targets and Flow session reliability fixes.
2026-06-20 00:31:58 +08:00
Rocky ef8115508c fix: onboarding permission refresh, keyboard l10n, and flow recording
Refresh mic/speech status when returning from TCC dialogs, persist onboarding
page across Settings trips, load extension strings from .lproj bundles via
ExtL10n, and keep flow result polling alive across host-app jumps.
2026-06-19 18:35:33 +08:00
Rocky 275fc81104 feat: complete Phase 4 batch F, keyboard i18n, and Pages app icon
Remove KeyboardL10n hard-coded fallbacks in favor of ExtL10n and extension
Localizable.strings. Add Flow session expiry hints, Darwin cross-process
notifications, session monitor on the keyboard, and app icon on GitHub Pages.
2026-06-19 18:20:25 +08:00
Rocky 7f059dbd45 feat: TypeWhisper Flow sessions, Phase 4 UX, and GitHub Pages privacy site
Migrate keyboard dictation to continuous Flow sessions with auto-start,
tap-to-toggle recording, 60s countdown, five-step onboarding, and App Group
IPC. Add docs/ GitHub Pages site with en/zh privacy policy for App Store compliance.
2026-06-19 18:05:50 +08:00
Rocky b1635d5d35 feat: iOS-style localization + onboarding engine picker + UX fixes
Five user-flagged issues addressed in this commit. Some of the
uncommitted files belong to a prior agent pass and are included as-is
so this is a clean working tree.

1. Remove globe (nextKeyboard) button from keyboard bottom bar.
   iOS already provides a globe key in the system keyboard strip
   for next-keyboard switching, so the in-extension one was
   redundant. The bar is now: ⌫ (delete) [space] ↩ (return).
   Affected: OSGKeyboardExt/Views/KeyboardRootView.swift and
   OSGKeyboard/Views/KeyboardPreviewStub.swift (preview mirrors).

2. Fix TabView with .page style auto-jumping on TextField focus.
   SwiftUI's `.tabViewStyle(.page(...))` wraps content in a
   UIPageViewController, which has a long-standing iOS 18 bug
   where the keyboard-showing layout reflow on a TextField focus
   is misread as a horizontal swipe — the page jumps back to
   step 1 the moment the user starts typing. Replaced the
   TabView with a ZStack + conditional view + transition. We give
   up swipe-to-page, but Back/Next buttons + page dots are the
   canonical onboarding affordance and the user is one tap from
   the next page anyway.

3. Onboarding APISetupPage now offers Engine choice (Local vs
   Cloud), matching the in-app Settings page. First-run users can
   pick the on-device engine and skip the API-key setup entirely.
   Extracted the engine section from SettingsView into a shared
   `EnginePickerSection` component used by both. Cloud path
   keeps the provider + API fields; Local path shows a
   "no API key needed" confirmation card.

4. APISettingsCard test-connection error messages are now built
   with NSLocalizedString + String.localizedStringWithFormat (for
   the interpolated HTTP status / reply preview). Status badge
   labels are also NSLocalizedString-backed.

5. iOS-standard localization.
   - New `en.lproj/Localizable.strings` and `zh-Hans.lproj/
     Localizable.strings` (in both the main app and the keyboard
     extension bundles — each .appex has its own bundle).
   - `project.yml` sets `CFBundleDevelopmentRegion: en` and
     `CFBundleLocalizations: [en, zh-Hans]`; the two .lproj dirs
     are added as resources.
   - Every `Text("...")` / `Button("...")` / `Label("...")` /
     `accessibilityLabel(Text("..."))` in user-facing views was
     rewritten to use `Text("key")` (SwiftUI auto-resolves
     string-literal `LocalizedStringKey`s against the strings
     file) or `NSLocalizedString("key", comment: "")` for
     interpolated / dynamic values. The hardcoded
     "中 · EN" / "EN · 中" pattern is gone.
   - Two helper signatures that took `String` for the title/body
     of a row (`footnoteRow`, `sectionHeader`) are now
     `LocalizedStringKey` so the row labels are looked up.
   - `Label("key", systemImage: "...")` and
     `.confirmationDialog(LocalizedStringKey("key"), ...)` and
     `.navigationTitle(LocalizedStringKey("key"))` are used where
     `String` would just print the key.
   - Preview-onboarding `APISetupPage` updated to use the same
     engine picker as Settings (issue 3), with a section that
     only renders the provider + API fields when the user picks
     Cloud.

Verified on iOS 26 simulator with system language set to both
zh-Hans (default) and en: the same screen renders "按住说话,松开
即得润色文字。" / "下一步" in Chinese, and "Hold to talk. Release
for polished text, in any app." / "Next" in English — no
"中 · EN" doubling, no missing keys.

Build: BUILD SUCCEEDED.
Tests: 21/21 pass.

🤖 Generated with Claude Code
2026-06-18 19:57:45 +08:00
Rocky aeb28f4b36 fix: green accent + real iOS ASR in preview + bilingual audit
Three user-flagged fixes, scoped tightly to the files each affects.
The uncommitted Engine-mode wiring / locale picker / etc. from a
prior agent pass is intentionally not included in this commit.

1. Revert accent to brand green (#3AA05A).
   Last commit flipped `AccentColor` + `Palette.light.accent` to
   Apple system blue (#007AFF) on the assumption that "green CTA
   on a near-white surface looks wrong." Review pushed back:
   the brand *is* the green, and the system tint should match it
   so that NavStack Done buttons, Toggles, and our custom
   `primaryButton()` modifier all read as the same colour. Restored
   `#3AA05A` in both `AccentColor.colorset/Contents.json` and
   `Palette.light.accent` (plus the muted / glow variants).

2. Real iOS ASR in `KeyboardPreviewSheet`.
   The previous fix only swapped the static placeholder for a
   `TextField` and routed a hardcoded stub through it — review
   asked, fairly, "are you actually calling `SFSpeechRecognizer`?"
   Answer: no. This change makes the preview *run real ASR*:

   - `ASRService` (+ the iOS 26 `SpeechAnalyzer` path) moves from
     `OSGKeyboardExt/Services/` to `OSGKeyboardShared/Services/`,
     so the host app can import the same `ASRServiceFactory.make()`
     the keyboard extension uses.
   - `OSGKeyboardShared` gains `Speech.framework` and
     `AVFoundation.framework` as SDK dependencies in `project.yml`.
   - New `OSGKeyboard/Views/PreviewASRController.swift` (the
     extension's `AudioCaptureService` is app-extension-only, so
     the preview owns its own `AVAudioEngine` + `AVAudioSession`
     and downsamples to 16 kHz mono Float32 via `AVAudioConverter`).
   - `KeyboardPreviewSheet.cyclePhase()` now calls
     `asr.start(locale:)` / `asr.stop()` instead of toggling a
     `StubPhase`. The disc's level meter is driven by RMS from the
     actual audio tap; the transcript line under the chips shows
     the live `SFSpeechRecognizer` partial; the top textbox
     receives the `.final` transcript via `onChange(of: lastFinal)`.

3. Record disc in BOTH stubs now uses the green accent for idle.
   Was `Color(white: 0.22)` dark-gray, which read as "inert
   surface" rather than "tap me". The keyboard extension and the
   in-app preview now share the same brand-green disc gradient so
   the keyboard's primary CTA is the same colour in both modes.

4. Bilingual audit across every user-facing string.
   Every Text() in the main-app and extension views is now
   `中文 · English` or carries an English secondary line. Covered:
   - OnboardingView (Back, Next, Done, Continue, step instructions,
     PrivacyFootnote rows)
   - HomeView (status header, hero label, accessibility)
   - APISettingsCard (Base URL, API Key, Model, "Get an API key",
     "Connection", test-connection states + error messages)
   - KeyboardPreviewSheet (title, subtitle, TextField placeholder,
     clear button accessibility)
   - KeyboardPreviewStub (mode/locale chips, REC badge, space bar)
   - KeyboardRootView (gear accessibility, space bar, requesting
     state, denied messages, local-engine chip)
   - RecordButton (accessibility label)
   - SettingsView (Done, Reset dialog, language section subtitle,
     engine section, local-engine subtitle, on-device label)
   - AppGroupErrorView (title, body, three remediation steps)
   - LLMProvider preset names and blurbs

   Where the prior pattern was "Chinese headline + English footnote"
   (e.g. OnboardingView's 启用 OSGKeyboard / Enable OSGKeyboard),
   that pattern was preserved — bilingual coverage means every
   screen reads as both, not that every line is rigidly `中 · EN`.

Build: BUILD SUCCEEDED on iPhone 17 Pro / iOS 26 simulator.
Tests: 21/21 pass (no test changes).
Visual: light-mode home shot at /tmp/osgk_light_green.png shows
the brand-green CTA restored across Next button, mic icon, and
page dot.

🤖 Generated with Claude Code
2026-06-18 19:34:53 +08:00
Rocky 3c11ce2903 feat: API key in Keychain + actionable permission-denied UX
Security: API key moves from App Group UserDefaults (plaintext on disk)
to the iOS Keychain. The host app writes in Settings; the keyboard
extension reads before each request. Cross-process sharing is via a new
shared keychain-access-group declared in both targets' entitlements.

UX: when the user denies microphone or speech-recognition permission,
the message becomes a tappable row that opens the host app's settings.
Previously the message said "请到「设置」中允许" but the only way to
actually get there was a top-bar ⚙ button that wasn't obviously
related. Auto-clear (2.4s) is now suppressed for .denied so the user
has time to read it. Re-pressing the mic from .denied re-checks
permission so the user can simply press again after granting.

API Keychain migration
----------------------
- New `OSGKeyboardShared/Services/Keychain.swift` — minimal
  `kSecClassGenericPassword` wrapper for one item
  (service "com.osgkeyboard.apikey", account "current"), backed by
  `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` (no iCloud sync).
  `setAPIKey("")` deletes the entry rather than storing an empty
  placeholder so "stored but empty" stays distinguishable from
  "not stored" for the noAPIKey error path.
- `ProviderConfig.apiKey` now reads/writes through Keychain instead
  of UserDefaults. `didSet` skips the round-trip when oldValue equals
  apiKey (init reads Keychain, then assigns — without this guard the
  init write would silently re-write the same value).
- One-shot migration: on first `ProviderConfig.init` after upgrade,
  a legacy `config.apiKey` UserDefaults entry is copied to Keychain
  and removed from UserDefaults. The legacy key is renamed in code to
  `apiKeyLegacy` so future reads of `config.apiKey` from UserDefaults
  would be a bug.
- `AppGroupStore.apiKey` reads from Keychain (was UserDefaults).
- Cross-process sharing: both targets' entitlements gain
  `com.apple.security.keychain-access-groups: ["com.osgkeyboard.shared"]`.
  `com.osgkeyboard.shared` is the first entry in both, so it becomes
  each process's default access group — Keychain queries don't need to
  specify `kSecAttrAccessGroup`.

Permission-denied UX
--------------------
- `KeyboardViewController.pressBegan` now accepts `.denied` and
  `.error` as starting states (previously only `.idle`), so pressing
  the mic after returning from Settings re-checks permission
  without waiting for an auto-clear.
- `scheduleAutoClearError` no longer clears `.denied` — only
  transient `.error` is timed. `.denied` is sticky until the user
  takes action (taps the row → settings, or presses mic → re-check).
- `TranscriptLine` `.denied` case now wraps the text in a Button
  that calls `state.openSettings`, with a `chevron.right` to make
  the affordance obvious. The text was shortened to
  "麦克风被拒绝" / "语音识别被拒绝" so the chevron has room and
  the action isn't implied twice (it was previously both in the
  text and via the top-bar ⚙ button).
- VoiceOver hint on the button: "Opens the OSGKeyboard settings
  page where you can grant microphone or speech recognition access."

Tests
-----
- New `OSGKeyboardTests/KeychainTests.swift` — 6 tests covering
  round-trip, empty-string-deletes, idempotent-delete,
  AppGroupStore-reads-from-Keychain, legacy UserDefaults → Keychain
  migration, and "Keychain wins when both are present".
- `LLMClientTests` setUp/tearDown now wipes the Keychain
  (`try? Keychain.deleteAPIKey()`) and clears `StubURLProtocolStorage`
  so tests are independent across runs in the same simulator process.
- All 21 tests pass (6 new + 15 existing).

🤖 Generated with Claude Code
2026-06-18 12:41:07 +08:00
hkgood 79be7384dd [JJC-20260618-005-D] P1/P2 cleanup: structured errors, privacy audit, timeout SSOT, view-model tests
13 items, 555-line diff, build + 15/15 tests green.

ARCH-A3: Phase.error now carries ErrorKind (micDenied/speechDenied/asr/llm/
appGroupUnavailable/unknown) so the UI can pick icons/copy without parsing
free-form strings. Phase.ErrorKind, Phase, LLMError all Equatable.

ARCH-A4: Every TextField in APISettingsCard gets .keyboardType(.asciiCapable)
to defeat SwiftUI's iOS 18 system-keyboard hand-off that auto-suggests
Chinese/emoji and corrupts API keys / URLs / model names.

ARCH-A5 + DOC-3: PrivacyInfo.xcprivacy audited for honesty. Removed three
declared-but-unused APIs (FileTimestamp / DiskSpace / SystemBootTime) and
added ActiveKeyboards (DDA9.1) to the extension (it actually calls
advanceToNextInputMode in the tap path). Main App now declares only
UserDefaults (CA92.1). CHANGELOG updated.

ARCH-A6: Extracted PermissionManager (mic+speech permission flow, iOS 17
branching) and AppGroupPersistor (App Group load/persist) from the God
Object. KeyboardViewController drops 515 → 459 lines. KeyboardPipelineController
left in-place per risk plan — pressBegan state machine is too race-sensitive
to refactor in this pass.

RED-2: Deleted unused Theme enum (no call sites).
RED-3: Deleted unused cardStyle() alias (no call sites).
RED-7: Single source of truth for LLM timeout — LLMClient.requestTimeout +
LLMClientFactory.defaultRequestTimeout; PolishingService derives timeout from
defaultRequestTimeout+1 instead of hardcoding 15.
RED-8: ASRService.transcribe now emits .capability(onDeviceSupported:) as
first event per session; StatusBadge shows REC ⚠️ when the locale fell
back to cloud. New @Published var onDeviceSupported on State.

TEST-1: testPolishThrowsOnTransportTimeout now actually exercises
cancellation: StubURLProtocol delays response 5s, client.polish is
cancelled via Task.cancel(), test asserts the client throws .cancelled /
.transport / .decoding (was: silently passed).

TEST-2: New testPolisherSkipsNetworkWhenModeOff — PolishingService now
short-circuits when modeId == 'off' and returns trimmed input without
invoking LLMClient (proved via injected CountingLLMClient). Service was
moved to OSGKeyboardShared to be reachable from the test target.

TEST-3: KeyboardState (formerly KeyboardViewController.State) extracted
into OSGKeyboardShared so tests can @testable-import it. 5 phase/mode
tests in new KeyboardStateTests. Typealias preserves the old name.

TEST-4: New OSGKeyboardExtTests target with 6 tests covering State
initial values, phase transitions, structured-error round-trip, mode
switching, and InputMode rawValue round-trip.
2026-06-18 12:27:15 +08:00
rocky 38ad66f07d [JJC-20260618-005-C-2] Refactor: replace 166 Palette.xxx with @Environment(\.themePalette) (ARCH-A2) 2026-06-18 12:02:58 +08:00
rocky 9fa64223af [JJC-20260618-005-B] Fix 6 critical bugs from review
- BUG-1: Restore KeyboardRootView top highlight + divider
- BUG-2: App Group unavailable -> friendly error view (no fatalError)
- BUG-3: pressBegan race condition (sync phase to .requestingPermissions)
- BUG-4: cancelled LLMError no longer re-inserts original text
- BUG-6: mode switch during recording flushes partial
- ARCH-A1: State.Phase add .requestingPermissions + .denied(Reason)

Notes:
- 8/8 tests pass (LLMClientTests)
- BUILD SUCCEEDED for both OSGKeyboard and OSGKeyboardExt
- New file AppGroupErrorView.swift auto-registered via XcodeGen
- Info.plist left untouched (NSSpeechRecognitionUsageDescription kept from A block)
2026-06-18 11:51:26 +08:00
rocky ed11a11329 [JJC-20260618-005-A] Review-driven P0 polish
6 review-driven small fixes to the P0 commit series (e0b92ff / e93bab5 /
2e2d8e3 / 013c498). No behavior changes, no new features, no push yet.

Code:
- RED-1: Remove empty init() from OSGKeyboardApp; @StateObject is initialized
  inline and the only thing init() did was print a DEBUG log.
- RED-4: Re-add .preferredColorScheme(.dark) to the 3 keyboard extension
  #Preview blocks. Custom keyboards always render dark; the preview should
  match.
- RED-6: ThemedRoot #Preview blocks now use the new EnvironmentKey
  (\.themePalette) via a private ThemedPreviewContent, instead of hard-coding
  Palette.dark / Palette.light.
- BUG-5: ThemedRoot body now also fills the background with the active
  palette's background color (repeats the colorScheme ternary — accepted
  because body must be a single expression).

Docs:
- DOC-1: CHANGELOG [Unreleased] top note: this is a v0.1.0 → v0.1.1 polish,
  nothing removed, iOS 26 SpeechAnalyzer still 0.2.0.
- DOC-2: README + README.zh.md Limitations: explicit note for iOS 26+ users
  in v0.1.1.

Verified: xcodebuild Debug-iphonesimulator BUILD SUCCEEDED, 8/8 unit
tests pass on iPhone 17 sim (iOS 26.3.1).
2026-06-18 11:29:14 +08:00
Zhongshu e0b92ff35a [P0-①] Theme follows system + keyboard background transparent
- Split Palette.dark / Palette.light into a ThemePalette struct
- Add EnvironmentKey<ThemePalette> + ThemedRoot<Content>
- Wrap OSGKeyboardApp root in ThemedRoot so main App follows system
- Remove 6 .preferredColorScheme(.dark) overrides from main App views
- KeyboardRootView drops its Palette.background fill, uses .background(Color.clear)
- Drop the unused 'background' computed property from KeyboardRootView
- Legacy 'Palette.xxx' static accessors still resolve to dark values,
  so 2000 lines of existing call sites stay untouched

xcodebuild iOS Simulator: SUCCEEDED
2026-06-18 10:38:23 +08:00
Rocky bec36befa2 feat: comprehensive rewrite — push-to-talk pipeline, Typeless UI, Chinese
This is a major rewrite of OpenLessKeyboard, renamed to OSGKeyboard
and rebuilt end-to-end. 59 files changed (+3205/-1550).

Architecture
------------
- Rename project, targets, directories from OpenLess* to OSGKeyboard*
  (OpenLess / OpenLessKeyboard / OpenLessShared / OpenLessTests).
- AudioCaptureService rewritten as @unchecked Sendable class with
  OSAllocatedUnfairLock instead of an actor, so it survives Swift 6
  strict-concurrency checks while still serialising engine + converter
  state correctly.
- Single design system (Palette / Spacing / Radius / TypeStyle /
  Motion) lifted into OSGKeyboardShared so the host app and the
  keyboard extension stay in lock-step.

Push-to-talk — first-principles fix
-----------------------------------
- App Group + audio-input entitlements were stripped by Xcode's
  Automatic Signing. They are now declared in project.yml so
  'xcodegen generate' re-emits them every time. iOS Developer
  Account is untouched; only the App Group capability was added.
- State machine uses a real stored `phase` (was a derived shim
  that locked out every press after the first because
  recordStream was never nilled after the pipeline finished).
- Microphone permission is requested inside pressBegan (async
  Task) so the press flow optimistically enters .recording;
  permission denial surfaces a short error and returns to idle.
- Replaced LongPressGesture(0.15s) with a DragGesture +
  TapGesture pair separated by pressArmed, so a single tap no
  longer fires both onPressBegan and onTap simultaneously.
- Real RMS / peak level meter from the AVAudioEngine tap (was a
  pseudo-random walk); the visible waveform is now driven by
  actual audio.
- SFSpeechRecognizer(locale:) with selectable ASR locales
  (auto / zh-Hans / zh-Hant / en-US / ja-JP / ko-KR) for
  first-class Chinese / English / Japanese / Korean dictation,
  with on-device recognition when supported.
- AVAudioSession now deactivates on stop so other apps' audio
  routing is restored.

Keyboard UI — Typeless-inspired layout
---------------------------------------
- Hero area is 280 pt with a 96 pt record disc, breathing outer
  ring, and a 12-bar waveform driven by the real RMS.
- inputView.allowsSelfSizing + a heightAnchor constraint so iOS
  no longer crops the keyboard under the Spotlight bar / home
  indicator.
- Top bar: mode chip (Off / 转写 / 润色) + locale chip
  (Auto / 简体 / 繁體 / EN / 日 / 한) + status badge + ⚙.
- Bottom bar: globe / delete / 空格 / return — all 40 pt and
  balanced.
- RecordButton onPressEnded is now safe to fire from a quick
  press; pressArmed prevents double-firing.

LLM / Polishing
---------------
- LLMClient: stopped leaking the server response body in errors
  (server body is now logged at debug, never surfaced to UI);
  added a dedicated .rateLimited case for 429.
- PolishingService timeout 8s → 12s to accommodate slower
  domestic LLM providers.
- AppGroupStore.defaultSystemPrompt is now provider-aware
  (Chinese for zhipu/moonshot/qwen/deepseek, English otherwise).

Onboarding & Settings
---------------------
- Re-themed OnboardingView / HomeView / SettingsView on the
  new design system.
- ProviderPickerSection now shows 6 providers (OpenAI, DeepSeek,
  Qwen DashScope, 智谱 GLM, 月之暗面 Moonshot, Custom) with
  blurb + selected accent.
- PickerRow for Mode and ASR locale; System Prompt editor with
  reset-to-default.
- API settings page "Get an API key" used SwiftUI Link, which
  has a hit-test bug on iOS 18 that ate gestures from adjacent
  TextFields (manifested as "typing jumps to a website"). It is
  now an explicit Button + contentShape + .submitLabel(.done) on
  the fields.

Polish & tests
--------------
- LLMClientTests: 4 unit tests passing (ProviderConfig
  persistence + OpenAI request/response + HTTP error + missing
  key); test App Group renamed to the correct identifier.
- ProviderConfig.apply now captures the previous provider id
  *before* mutating, so switching providers actually resets the
  system prompt to the new default.

Build
-----
- Swift 6 strict concurrency, iOS 18.0 deployment target.
- Tested on Xcode 26 + iPhone 17 Pro simulator. A real device on
  iOS 27 beta aborts with __abort_with_payload (dispatch
  library ABI mismatch); use an iOS 18 real device or the
  iOS 26 simulator for now.

🤖 Generated with Claude Code
2026-06-18 01:22:12 +08:00