Files
OSGKeyboard/OSGKeyboardShared/DesignSystem/Theme.swift
T
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

236 lines
9.7 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.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
/// 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.
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.000, green: 0.478, blue: 1.000), // iOS systemBlue
accentMuted: Color(red: 0.000, green: 0.478, blue: 1.000).opacity(0.14),
accentGlow: Color(red: 0.000, green: 0.478, blue: 1.000).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
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)
}
}