bec36befa2
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
157 lines
6.2 KiB
Swift
157 lines
6.2 KiB
Swift
// Theme.swift
|
|
// OSGKeyboard · Design System
|
|
//
|
|
// Single source of truth for colour, spacing, corner radius, typography.
|
|
// Inspired by Dieter Rams ("less but better") and Apple Human Interface:
|
|
// every surface has a single purpose, every token earns its place, and
|
|
// the visual hierarchy is carried by *whitespace + one accent*, never by
|
|
// extra colour.
|
|
|
|
import SwiftUI
|
|
|
|
// MARK: - Palette
|
|
|
|
public enum Palette {
|
|
// Backgrounds
|
|
public static let background = Color(red: 0.039, green: 0.039, blue: 0.043) // #0A0A0B
|
|
public static let surface = Color(red: 0.094, green: 0.094, blue: 0.106) // #18181B
|
|
public static let surfaceElevated = Color(red: 0.153, green: 0.153, blue: 0.169) // #27272A
|
|
public static let surfaceMuted = Color(red: 0.071, green: 0.071, blue: 0.082) // #121215
|
|
|
|
// Accents
|
|
public static let accent = Color(red: 0.353, green: 0.784, blue: 0.980) // #5AC8FA
|
|
public static let accentMuted = accent.opacity(0.18)
|
|
public static let accentGlow = accent.opacity(0.42)
|
|
|
|
// Semantic
|
|
public static let danger = Color(red: 1.000, green: 0.271, blue: 0.227) // #FF453A
|
|
public static let success = Color(red: 0.157, green: 0.812, blue: 0.412) // #28CF69
|
|
public static let warning = Color(red: 1.000, green: 0.749, blue: 0.094) // #FFBF18
|
|
|
|
// Text
|
|
public static let textPrimary = Color.white
|
|
public static let textSecondary = Color(white: 0.7)
|
|
public static let textTertiary = Color(white: 0.50)
|
|
public static let textOnAccent = Color.black
|
|
|
|
// Lines
|
|
public static let divider = Color.white.opacity(0.06)
|
|
public static let dividerStrong = Color.white.opacity(0.10)
|
|
|
|
// Recording state
|
|
public static let recordRed = Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30
|
|
}
|
|
|
|
// MARK: - Spacing scale (4 pt grid)
|
|
|
|
public enum Spacing {
|
|
public static let xxs: CGFloat = 4
|
|
public static let xs: CGFloat = 8
|
|
public static let sm: CGFloat = 12
|
|
public static let md: CGFloat = 16
|
|
public static let lg: CGFloat = 20
|
|
public static let xl: CGFloat = 24
|
|
public static let xxl: CGFloat = 32
|
|
public static let xxxl: CGFloat = 40
|
|
public static let hero: CGFloat = 48
|
|
}
|
|
|
|
// MARK: - Corner radius scale
|
|
|
|
public enum Radius {
|
|
public static let small: CGFloat = 8
|
|
public static let medium: CGFloat = 12
|
|
public static let large: CGFloat = 16
|
|
public static let xl: CGFloat = 20
|
|
public static let xxl: CGFloat = 24
|
|
public static let pill: CGFloat = 999
|
|
}
|
|
|
|
// MARK: - Typography
|
|
|
|
public enum TypeStyle {
|
|
public static let caption2 = Font.system(size: 11, weight: .medium)
|
|
public static let caption = Font.system(size: 12, weight: .medium)
|
|
public static let footnote = Font.system(size: 13, weight: .regular)
|
|
public static let body = Font.system(size: 15, weight: .regular)
|
|
public static let bodyEmph = Font.system(size: 15, weight: .medium)
|
|
public static let headline = Font.system(size: 17, weight: .semibold)
|
|
public static let title3 = Font.system(size: 20, weight: .semibold)
|
|
public static let title2 = Font.system(size: 22, weight: .bold)
|
|
public static let title = Font.system(size: 28, weight: .bold)
|
|
public static let largeTitle = Font.system(size: 34, weight: .bold)
|
|
public static let mono = Font.system(size: 13, weight: .regular, design: .monospaced)
|
|
public static let monoSmall = Font.system(size: 11, weight: .regular, design: .monospaced)
|
|
}
|
|
|
|
// MARK: - Animation
|
|
|
|
public enum Motion {
|
|
public static let quick = Animation.spring(response: 0.25, dampingFraction: 0.85)
|
|
public static let soft = Animation.spring(response: 0.40, dampingFraction: 0.80)
|
|
public static let deliberate = Animation.spring(response: 0.55, dampingFraction: 0.78)
|
|
public static let breath = Animation.easeInOut(duration: 1.6).repeatForever(autoreverses: true)
|
|
public static let instant = Animation.linear(duration: 0.12)
|
|
}
|
|
|
|
// MARK: - Reusable view modifiers
|
|
|
|
public extension View {
|
|
/// Standard card surface used in the main app.
|
|
func cardSurface(padding: CGFloat = Spacing.md) -> some View {
|
|
self
|
|
.padding(padding)
|
|
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(Palette.divider, lineWidth: 0.5)
|
|
)
|
|
}
|
|
|
|
/// Muted pill (used for tags, locale indicators, etc.).
|
|
func pillChip(foreground: Color = Palette.textSecondary) -> some View {
|
|
self
|
|
.padding(.horizontal, Spacing.xs)
|
|
.padding(.vertical, 4)
|
|
.background(Palette.surfaceElevated, in: Capsule())
|
|
.foregroundStyle(foreground)
|
|
}
|
|
|
|
/// Primary CTA button.
|
|
func primaryButton() -> some View {
|
|
self
|
|
.font(TypeStyle.headline)
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
.background(Palette.accent, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.foregroundStyle(Palette.textOnAccent)
|
|
}
|
|
|
|
/// Secondary CTA button.
|
|
func secondaryButton() -> some View {
|
|
self
|
|
.font(TypeStyle.headline)
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
.background(Palette.surface, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: Radius.large, style: .continuous)
|
|
.stroke(Palette.dividerStrong, lineWidth: 0.5)
|
|
)
|
|
.foregroundStyle(Palette.textPrimary)
|
|
}
|
|
|
|
/// Legacy alias for older call sites.
|
|
func cardStyle() -> some View { cardSurface() }
|
|
}
|
|
|
|
// MARK: - Backwards compat (legacy callers in old code)
|
|
|
|
public enum Theme {
|
|
public static let background = Palette.background
|
|
public static let card = Palette.surface
|
|
public static let accent = Palette.accent
|
|
public static let danger = Palette.danger
|
|
public static let textPrimary = Palette.textPrimary
|
|
public static let textSecondary = Palette.textSecondary
|
|
public static let divider = Palette.divider
|
|
}
|