aeb28f4b36
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
282 lines
11 KiB
Swift
282 lines
11 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: - Theme palette (light / dark)
|
|
|
|
/// Single source of truth for *one* colour scheme. The active palette is
|
|
/// injected via the `\.themePalette` environment key — see
|
|
/// `DesignSystem/ThemedRoot.swift`. Token names mirror the previous
|
|
/// `Palette` static API so existing call sites (`Palette.background` etc.)
|
|
/// still compile and resolve through the legacy static accessors below.
|
|
public struct ThemePalette: Sendable {
|
|
public let background: Color
|
|
public let surface: Color
|
|
public let surfaceElevated: Color
|
|
public let surfaceMuted: Color
|
|
|
|
public let accent: Color
|
|
public let accentMuted: Color
|
|
public let accentGlow: Color
|
|
|
|
public let danger: Color
|
|
public let success: Color
|
|
public let warning: Color
|
|
|
|
public let textPrimary: Color
|
|
public let textSecondary: Color
|
|
public let textTertiary: Color
|
|
public let textOnAccent: Color
|
|
|
|
public let divider: Color
|
|
public let dividerStrong: Color
|
|
|
|
public let recordRed: Color
|
|
}
|
|
|
|
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.227, green: 0.627, blue: 0.353) // #3AA05A
|
|
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
|
|
|
|
/// Canonical dark palette — preserves every legacy literal above so
|
|
/// existing call sites that read `Palette.background` directly keep
|
|
/// getting the dark value (important for the keyboard extension, which
|
|
/// deliberately stays dark regardless of system appearance).
|
|
public static let dark = ThemePalette(
|
|
background: background,
|
|
surface: surface,
|
|
surfaceElevated: surfaceElevated,
|
|
surfaceMuted: surfaceMuted,
|
|
accent: accent,
|
|
accentMuted: accentMuted,
|
|
accentGlow: accentGlow,
|
|
danger: danger,
|
|
success: success,
|
|
warning: warning,
|
|
textPrimary: textPrimary,
|
|
textSecondary: textSecondary,
|
|
textTertiary: textTertiary,
|
|
textOnAccent: textOnAccent,
|
|
divider: divider,
|
|
dividerStrong: dividerStrong,
|
|
recordRed: recordRed
|
|
)
|
|
|
|
/// Light palette — iOS system light mode defaults. Used by the main app
|
|
/// when the user is in light mode; the keyboard extension stays dark.
|
|
///
|
|
/// Accent is the same brand green (#3AA05A) used in the dark palette —
|
|
/// "one accent" is core to the design system, and the recording-state
|
|
/// CTA needs to read as the same brand colour in both modes. The
|
|
/// keyboard's record disc also picks up `palette.accent` (see
|
|
/// `KeyboardPreviewStub.recordDisc`), so this single token drives
|
|
/// every interactive surface.
|
|
public static let light = ThemePalette(
|
|
background: Color(red: 0.980, green: 0.980, blue: 0.988), // #FAFAFC
|
|
surface: Color(red: 1.000, green: 1.000, blue: 1.000), // #FFFFFF
|
|
surfaceElevated: Color(red: 0.941, green: 0.941, blue: 0.961), // #F0F0F5
|
|
surfaceMuted: Color(red: 0.953, green: 0.953, blue: 0.965), // #F3F3F6
|
|
accent: Color(red: 0.227, green: 0.627, blue: 0.353), // #3AA05A
|
|
accentMuted: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.14),
|
|
accentGlow: Color(red: 0.227, green: 0.627, blue: 0.353).opacity(0.32),
|
|
danger: Color(red: 1.000, green: 0.231, blue: 0.188), // #FF3B30
|
|
success: Color(red: 0.157, green: 0.812, blue: 0.412), // #28CF69
|
|
warning: Color(red: 1.000, green: 0.620, blue: 0.094), // #FF9E18
|
|
textPrimary: Color(red: 0.067, green: 0.067, blue: 0.094), // #111118
|
|
textSecondary: Color(red: 0.392, green: 0.392, blue: 0.435), // #64646F
|
|
textTertiary: Color(red: 0.557, green: 0.557, blue: 0.604), // #8E8E9A
|
|
textOnAccent: Color.white,
|
|
divider: Color.black.opacity(0.06),
|
|
dividerStrong: Color.black.opacity(0.10),
|
|
recordRed: Color(red: 1.000, green: 0.231, blue: 0.188) // #FF3B30
|
|
)
|
|
}
|
|
|
|
// MARK: - Environment key
|
|
|
|
private struct ThemePaletteKey: EnvironmentKey {
|
|
/// Default falls back to the legacy dark palette so views that haven't
|
|
/// been wrapped in `ThemedRoot` continue to look identical to today.
|
|
static let defaultValue: ThemePalette = Palette.dark
|
|
}
|
|
|
|
public extension EnvironmentValues {
|
|
/// The palette currently active for this view. Reads from the nearest
|
|
/// `ThemedRoot` ancestor (or `Palette.dark` if none).
|
|
var themePalette: ThemePalette {
|
|
get { self[ThemePaletteKey.self] }
|
|
set { self[ThemePaletteKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
// 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
|
|
//
|
|
// Each modifier is a proper ViewModifier struct so it can read the
|
|
// active ThemePalette from @Environment. This is the ONLY way to make
|
|
// shared modifiers respect light/dark mode — plain View extension
|
|
// methods cannot access environment values.
|
|
|
|
private struct CardSurfaceModifier: ViewModifier {
|
|
@Environment(\.themePalette) private var palette
|
|
let padding: CGFloat
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.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)
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct PillChipModifier: ViewModifier {
|
|
@Environment(\.themePalette) private var palette
|
|
let foreground: Color?
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.padding(.horizontal, Spacing.xs)
|
|
.padding(.vertical, 4)
|
|
.background(palette.surfaceElevated, in: Capsule())
|
|
.foregroundStyle(foreground ?? palette.textSecondary)
|
|
}
|
|
}
|
|
|
|
private struct PrimaryButtonModifier: ViewModifier {
|
|
@Environment(\.themePalette) private var palette
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.font(TypeStyle.headline)
|
|
.frame(maxWidth: .infinity, minHeight: 50)
|
|
.background(palette.accent, in: RoundedRectangle(cornerRadius: Radius.large, style: .continuous))
|
|
.foregroundStyle(palette.textOnAccent)
|
|
}
|
|
}
|
|
|
|
private struct SecondaryButtonModifier: ViewModifier {
|
|
@Environment(\.themePalette) private var palette
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.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)
|
|
}
|
|
}
|
|
|
|
public extension View {
|
|
/// Standard card surface used in the main app.
|
|
func cardSurface(padding: CGFloat = Spacing.md) -> some View {
|
|
modifier(CardSurfaceModifier(padding: padding))
|
|
}
|
|
|
|
/// Muted pill (used for tags, locale indicators, etc.).
|
|
/// Pass nil to inherit palette.textSecondary automatically.
|
|
func pillChip(foreground: Color? = nil) -> some View {
|
|
modifier(PillChipModifier(foreground: foreground))
|
|
}
|
|
|
|
/// Primary CTA button.
|
|
func primaryButton() -> some View {
|
|
modifier(PrimaryButtonModifier())
|
|
}
|
|
|
|
/// Secondary CTA button.
|
|
func secondaryButton() -> some View {
|
|
modifier(SecondaryButtonModifier())
|
|
}
|
|
}
|