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.
This commit is contained in:
Rocky
2026-06-19 18:20:25 +08:00
parent 7f059dbd45
commit 275fc81104
14 changed files with 449 additions and 113 deletions
@@ -111,6 +111,7 @@ final class FlowSessionManager: ObservableObject {
} }
FlowSessionBridge.writeHeartbeat() FlowSessionBridge.writeHeartbeat()
FlowSessionDarwin.postSessionChanged()
isActive = true isActive = true
if let expires = FlowSessionBridge.sessionExpiresAt() { if let expires = FlowSessionBridge.sessionExpiresAt() {
sessionExpiresAt = Date(timeIntervalSince1970: expires) sessionExpiresAt = Date(timeIntervalSince1970: expires)
@@ -151,6 +152,7 @@ final class FlowSessionManager: ObservableObject {
capture.stop() capture.stop()
FlowSessionBridge.markSessionInactive() FlowSessionBridge.markSessionInactive()
FlowSessionDarwin.postSessionChanged()
isActive = false isActive = false
sessionExpiresAt = nil sessionExpiresAt = nil
sessionWarning = nil sessionWarning = nil
@@ -186,6 +188,7 @@ final class FlowSessionManager: ObservableObject {
} }
FlowSessionBridge.markSessionActive(duration: duration) FlowSessionBridge.markSessionActive(duration: duration)
FlowSessionDarwin.postSessionChanged()
isActive = true isActive = true
sessionExpiresAt = Date().addingTimeInterval(duration) sessionExpiresAt = Date().addingTimeInterval(duration)
+79 -24
View File
@@ -62,6 +62,9 @@ public final class KeyboardViewController: UIInputViewController {
private var flowWatchdogTask: Task<Void, Never>? private var flowWatchdogTask: Task<Void, Never>?
private var utteranceTimerTask: Task<Void, Never>? private var utteranceTimerTask: Task<Void, Never>?
private var utteranceStartedAt: TimeInterval = 0 private var utteranceStartedAt: TimeInterval = 0
private var wasFlowSessionActive = false
private var flowSessionMonitorTask: Task<Void, Never>?
private var flowSessionDarwinObserver: FlowSessionDarwinObserver?
// MARK: - Lifecycle // MARK: - Lifecycle
@@ -76,17 +79,22 @@ public final class KeyboardViewController: UIInputViewController {
loadPersistedConfig() loadPersistedConfig()
consumePendingDictationResultIfNeeded() consumePendingDictationResultIfNeeded()
refreshDictationProgressStateIfNeeded() refreshDictationProgressStateIfNeeded()
installFlowSessionDarwinObserver()
refreshFlowSessionState()
} }
public override func viewWillDisappear(_ animated: Bool) { public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated) super.viewWillDisappear(animated)
cancelPipeline() cancelPipeline()
stopFlowSessionMonitor()
} }
public override func viewWillAppear(_ animated: Bool) { public override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
consumePendingDictationResultIfNeeded() consumePendingDictationResultIfNeeded()
refreshDictationProgressStateIfNeeded() refreshDictationProgressStateIfNeeded()
refreshFlowSessionState()
startFlowSessionMonitor()
} }
public override func didReceiveMemoryWarning() { public override func didReceiveMemoryWarning() {
@@ -142,10 +150,57 @@ public final class KeyboardViewController: UIInputViewController {
case .loaded: case .loaded:
break break
case .unavailable: case .unavailable:
state.phase = .error(.appGroupUnavailable, message: "App Group 未配置") state.phase = .error(
.appGroupUnavailable,
message: ExtL10n.string("keyboard.error.appGroupUnavailable")
)
} }
} }
// MARK: - Flow session monitor
private func installFlowSessionDarwinObserver() {
flowSessionDarwinObserver = FlowSessionDarwinObserver { [weak self] in
self?.refreshFlowSessionState()
}
}
private func startFlowSessionMonitor() {
flowSessionMonitorTask?.cancel()
flowSessionMonitorTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
self?.refreshFlowSessionState()
try? await Task.sleep(nanoseconds: 1_000_000_000)
}
}
}
private func stopFlowSessionMonitor() {
flowSessionMonitorTask?.cancel()
flowSessionMonitorTask = nil
}
private func refreshFlowSessionState() {
let active = FlowSessionBridge.isSessionActive()
state.flowSessionActive = active
if wasFlowSessionActive && !active && !isFlowRecording && !isPendingFlowStart {
switch state.phase {
case .recording, .processing:
break
default:
showFlowSessionExpiredHint()
}
}
wasFlowSessionActive = active
}
private func showFlowSessionExpiredHint() {
let message = ExtL10n.string("keyboard.flow.sessionExpired")
state.phase = .error(.unknown(message), message: message)
scheduleAutoClearError()
}
// MARK: - Press handlers // MARK: - Press handlers
private func toggleRecording() { private func toggleRecording() {
@@ -168,13 +223,13 @@ public final class KeyboardViewController: UIInputViewController {
} }
guard state.mode != .off else { return } guard state.mode != .off else { return }
guard hasFullAccess else { guard hasFullAccess else {
let msg = "请在系统设置中为 OSGKeyboard 开启“允许完全访问”,否则无法使用语音输入" let msg = ExtL10n.string("keyboard.error.fullAccessRequired")
state.phase = .error(.unknown(msg), message: msg) state.phase = .error(.unknown(msg), message: msg)
scheduleAutoClearError() scheduleAutoClearError()
return return
} }
guard AppGroup.isAvailable else { guard AppGroup.isAvailable else {
let msg = "App Group 未配置,键盘无法与主 App 通信。请重新安装并检查签名配置。" let msg = ExtL10n.string("keyboard.error.appGroupCommunication")
state.phase = .error(.appGroupUnavailable, message: msg) state.phase = .error(.appGroupUnavailable, message: msg)
scheduleAutoClearError() scheduleAutoClearError()
return return
@@ -198,7 +253,7 @@ public final class KeyboardViewController: UIInputViewController {
stopUtteranceCountdown() stopUtteranceCountdown()
FlowSessionBridge.setRecordingState(.stopped) FlowSessionBridge.setRecordingState(.stopped)
state.phase = .processing state.phase = .processing
state.lastTranscript = "识别中..." state.lastTranscript = ExtL10n.string("keyboard.flow.transcribing")
startFlowResultWatchdog() startFlowResultWatchdog()
} }
@@ -245,7 +300,7 @@ public final class KeyboardViewController: UIInputViewController {
isPendingFlowStart = true isPendingFlowStart = true
isFlowRecording = false isFlowRecording = false
flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout flowStartDeadline = Date().timeIntervalSince1970 + FlowWatchdog.startTimeout
state.lastTranscript = "正在启动语音会话..." state.lastTranscript = ExtL10n.string("keyboard.flow.startingSession")
state.phase = .processing state.phase = .processing
openHostApp(path: "startflow") openHostApp(path: "startflow")
startFlowStartWatchdog() startFlowStartWatchdog()
@@ -312,7 +367,7 @@ public final class KeyboardViewController: UIInputViewController {
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
if now - startedAt > FlowWatchdog.resultTimeout { if now - startedAt > FlowWatchdog.resultTimeout {
self.stopFlowWatchdog() self.stopFlowWatchdog()
let msg = "等待识别结果超时,请重试" let msg = ExtL10n.string("keyboard.flow.resultTimeout")
self.state.phase = .error(.unknown(msg), message: msg) self.state.phase = .error(.unknown(msg), message: msg)
self.scheduleAutoClearError() self.scheduleAutoClearError()
return return
@@ -396,13 +451,13 @@ public final class KeyboardViewController: UIInputViewController {
// Don't silently insert the raw transcript the user // Don't silently insert the raw transcript the user
// thinks they're getting polished text when really no // thinks they're getting polished text when really no
// key is configured. Show a precise, actionable error. // key is configured. Show a precise, actionable error.
self.state.phase = .error(.llm(error), message: "未配置 API Key · 请在主 App 设置中填写") self.state.phase = .error(.llm(error), message: ExtL10n.string("keyboard.error.llm.noApiKey"))
self.scheduleAutoClearError() self.scheduleAutoClearError()
case .http(401): case .http(401):
self.state.phase = .error(.llm(error), message: "API Key 无效 (401) · 请检查主 App 设置") self.state.phase = .error(.llm(error), message: ExtL10n.string("keyboard.error.llm.unauthorized"))
self.scheduleAutoClearError() self.scheduleAutoClearError()
case .http(429), .rateLimited: case .http(429), .rateLimited:
self.state.phase = .error(.llm(error), message: "API 限流 (429) · 请稍后再试") self.state.phase = .error(.llm(error), message: ExtL10n.string("keyboard.error.llm.rateLimited"))
self.scheduleAutoClearError() self.scheduleAutoClearError()
case .cancelled: case .cancelled:
// User-initiated cancellation (e.g. mode switch mid- // User-initiated cancellation (e.g. mode switch mid-
@@ -464,7 +519,7 @@ public final class KeyboardViewController: UIInputViewController {
private func openHostApp(path: String = "settings") { private func openHostApp(path: String = "settings") {
guard hasFullAccess else { guard hasFullAccess else {
let msg = "未开启“允许完全访问”,请先在键盘设置中打开" let msg = ExtL10n.string("keyboard.error.fullAccessForJump")
state.phase = .error(.unknown(msg), message: msg) state.phase = .error(.unknown(msg), message: msg)
scheduleAutoClearError() scheduleAutoClearError()
return return
@@ -485,7 +540,7 @@ public final class KeyboardViewController: UIInputViewController {
// Flow start: auto-jump often fails in WeChat/Safari keep polling // Flow start: auto-jump often fails in WeChat/Safari keep polling
// so a manually opened host app can still satisfy the session check. // so a manually opened host app can still satisfy the session check.
if path == "startflow", isPendingFlowStart { if path == "startflow", isPendingFlowStart {
state.lastTranscript = "无法自动跳转,请从主屏幕打开 OSGKeyboard,然后返回继续" state.lastTranscript = ExtL10n.string("keyboard.flow.manualOpenHost")
return return
} }
@@ -508,18 +563,18 @@ public final class KeyboardViewController: UIInputViewController {
switch progress.status { switch progress.status {
case .requested: case .requested:
state.lastTranscript = state.isLocalEngine state.lastTranscript = state.isLocalEngine
? "正在打开 OSGKeyboard(本地转写)..." ? ExtL10n.string("keyboard.dictation.openingLocal")
: "正在打开 OSGKeyboard..." : ExtL10n.string("keyboard.dictation.opening")
case .recording: case .recording:
state.lastTranscript = state.isLocalEngine state.lastTranscript = state.isLocalEngine
? "正在本地录音,请完成后返回当前输入页" ? ExtL10n.string("keyboard.dictation.recordingLocal")
: "正在录音,请完成后返回当前输入页" : ExtL10n.string("keyboard.dictation.recording")
case .transcribing: case .transcribing:
state.lastTranscript = state.isLocalEngine state.lastTranscript = state.isLocalEngine
? "本地识别中,请稍候并返回输入页" ? ExtL10n.string("keyboard.dictation.transcribingLocal")
: "识别中,请稍候并返回输入页" : ExtL10n.string("keyboard.dictation.transcribing")
case .error: case .error:
let msg = progress.message ?? "录音失败,请重试" let msg = progress.message ?? ExtL10n.string("keyboard.dictation.failed")
debug("host returned error: \(msg)") debug("host returned error: \(msg)")
awaitingDictationResult = false awaitingDictationResult = false
stopDictationWatchdog() stopDictationWatchdog()
@@ -538,7 +593,7 @@ public final class KeyboardViewController: UIInputViewController {
let now = Date().timeIntervalSince1970 let now = Date().timeIntervalSince1970
let lastProgressAt = progress.updatedAt > 0 ? progress.updatedAt : dictationRequestStartedAt let lastProgressAt = progress.updatedAt > 0 ? progress.updatedAt : dictationRequestStartedAt
if now - lastProgressAt > DictationWatchdog.timeout { if now - lastProgressAt > DictationWatchdog.timeout {
let timeoutMessage = "等待录音结果超时,请返回 OSGKeyboard 完成录音后重试" let timeoutMessage = ExtL10n.string("keyboard.dictation.resultTimeout")
debug("dictation timeout after \(Int(now - lastProgressAt))s") debug("dictation timeout after \(Int(now - lastProgressAt))s")
awaitingDictationResult = false awaitingDictationResult = false
stopDictationWatchdog() stopDictationWatchdog()
@@ -551,15 +606,15 @@ public final class KeyboardViewController: UIInputViewController {
private func showManualSettingsHint(path: String = "settings") { private func showManualSettingsHint(path: String = "settings") {
let msg: String let msg: String
if !hasFullAccess { if !hasFullAccess {
msg = "请先开启 OSGKeyboard 的“允许完全访问”,否则键盘无法跳转到 App" msg = ExtL10n.string("keyboard.error.fullAccessForJump")
} else if path == "settings" { } else if path == "settings" {
msg = "系统拒绝了键盘跳转。请手动打开 OSGKeyboard App 进入设置页" msg = ExtL10n.string("keyboard.error.manualOpenSettings")
} else if path == "startflow" { } else if path == "startflow" {
msg = "语音会话未启动。请从主屏幕打开 OSGKeyboard App,返回后再按麦克风" msg = ExtL10n.string("keyboard.error.manualOpenForFlow")
} else if state.isLocalEngine { } else if state.isLocalEngine {
msg = "系统拒绝了键盘跳转。请先手动打开 OSGKeyboard 完成本地转写,再返回输入页" msg = ExtL10n.string("keyboard.error.manualOpenDictateLocal")
} else { } else {
msg = "系统拒绝了键盘跳转。请先手动打开 OSGKeyboard 录音,再返回输入页" msg = ExtL10n.string("keyboard.error.manualOpenDictate")
} }
state.phase = .error(.unknown(msg), message: msg) state.phase = .error(.unknown(msg), message: msg)
scheduleAutoClearError() scheduleAutoClearError()
+17
View File
@@ -0,0 +1,17 @@
// ExtL10n.swift
// OSGKeyboard · Keyboard Extension
//
// Loads strings from the extension bundle. Replaces the old KeyboardL10n
// hard-coded fallback map keys live in Localizable.strings.
import Foundation
enum ExtL10n {
static func string(_ key: String) -> String {
NSLocalizedString(key, bundle: .main, comment: "")
}
static func format(_ key: String, _ args: CVarArg...) -> String {
String(format: string(key), locale: Locale.current, arguments: args)
}
}
+42 -63
View File
@@ -86,7 +86,7 @@ public struct KeyboardRootView: View {
.overlay(Circle().stroke(palette.divider, lineWidth: 0.5)) .overlay(Circle().stroke(palette.divider, lineWidth: 0.5))
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Text(KeyboardL10n.openSettingsA11y)) .accessibilityLabel(Text("keyboard.openSettingsA11y"))
} }
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
} }
@@ -99,6 +99,7 @@ public struct KeyboardRootView: View {
TranscriptLine( TranscriptLine(
phase: state.phase, phase: state.phase,
transcript: state.lastTranscript, transcript: state.lastTranscript,
flowSessionActive: state.flowSessionActive,
openSettings: state.openSettings openSettings: state.openSettings
) )
.frame(height: 22) .frame(height: 22)
@@ -128,7 +129,7 @@ public struct KeyboardRootView: View {
state.deleteBackward() state.deleteBackward()
} }
Button(action: state.insertSpace) { Button(action: state.insertSpace) {
Text(KeyboardL10n.space) Text("keyboard.space")
.font(TypeStyle.body) .font(TypeStyle.body)
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.frame(maxWidth: .infinity, minHeight: 42) .frame(maxWidth: .infinity, minHeight: 42)
@@ -139,7 +140,7 @@ public struct KeyboardRootView: View {
) )
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityLabel(Text(KeyboardL10n.space)) .accessibilityLabel(Text("keyboard.space"))
ToolbarIconButton(systemName: "return", label: "newline") { ToolbarIconButton(systemName: "return", label: "newline") {
state.insertNewline() state.insertNewline()
} }
@@ -194,19 +195,26 @@ private struct TranscriptLine: View {
let phase: KeyboardViewController.State.Phase let phase: KeyboardViewController.State.Phase
let transcript: String let transcript: String
let flowSessionActive: Bool
let openSettings: () -> Void let openSettings: () -> Void
var body: some View { var body: some View {
ZStack { ZStack {
switch phase { switch phase {
case .idle: case .idle:
Text(KeyboardL10n.placeholderIdle) if flowSessionActive {
Text("keyboard.placeholder.idle")
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary) .foregroundStyle(palette.textTertiary)
} else {
Text("keyboard.flow.sessionInactive")
.font(TypeStyle.caption)
.foregroundStyle(palette.textTertiary)
}
case .requestingPermissions: case .requestingPermissions:
HStack(spacing: 6) { HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.textSecondary) ProgressView().controlSize(.mini).tint(palette.textSecondary)
Text(KeyboardL10n.placeholderPreparing) Text("keyboard.placeholder.preparing")
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
@@ -220,7 +228,7 @@ private struct TranscriptLine: View {
case .processing: case .processing:
HStack(spacing: 6) { HStack(spacing: 6) {
ProgressView().controlSize(.mini).tint(palette.accent) ProgressView().controlSize(.mini).tint(palette.accent)
Text(transcript.isEmpty ? KeyboardL10n.placeholderProcessing : transcript) Text(transcript.isEmpty ? String(localized: "keyboard.placeholder.processing") : transcript)
.font(TypeStyle.caption) .font(TypeStyle.caption)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
@@ -247,17 +255,17 @@ private struct TranscriptLine: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.accessibilityHint(Text(KeyboardL10n.deniedHint)) .accessibilityHint(Text("keyboard.deniedHint"))
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.horizontal, Spacing.md) .padding(.horizontal, Spacing.md)
} }
private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> String { private func deniedMessage(for reason: KeyboardViewController.State.Phase.Reason) -> LocalizedStringKey {
switch reason { switch reason {
case .mic: return KeyboardL10n.micDenied case .mic: return "keyboard.denied.mic"
case .speech: return KeyboardL10n.speechDenied case .speech: return "keyboard.denied.speech"
} }
} }
} }
@@ -309,21 +317,21 @@ private struct StatusBadge: View {
EmptyView() EmptyView()
case .recording: case .recording:
if onDeviceSupported { if onDeviceSupported {
dot(color: palette.recordRed, label: "REC") dot(color: palette.recordRed, labelKey: "keyboard.status.rec")
} else { } else {
dot(color: palette.warning, label: "REC ⚠️", showWarning: true) dot(color: palette.warning, labelKey: "keyboard.status.recWarning", showWarning: true)
} }
case .processing: case .processing:
dot(color: palette.accent, label: "···") dot(color: palette.accent, labelKey: "keyboard.status.processing")
case .error: case .error:
dot(color: palette.warning, label: "!") dot(color: palette.warning, labelKey: "keyboard.status.error")
case .denied: case .denied:
dot(color: palette.warning, label: "!") dot(color: palette.warning, labelKey: "keyboard.status.error")
} }
} }
} }
private func dot(color: Color, label: String, showWarning: Bool = false) -> some View { private func dot(color: Color, labelKey: LocalizedStringKey, showWarning: Bool = false) -> some View {
HStack(spacing: 4) { HStack(spacing: 4) {
Circle() Circle()
.fill(color) .fill(color)
@@ -333,7 +341,7 @@ private struct StatusBadge: View {
.font(.system(size: 9, weight: .bold)) .font(.system(size: 9, weight: .bold))
.foregroundStyle(palette.warning) .foregroundStyle(palette.warning)
} }
Text(label) Text(labelKey)
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
} }
@@ -352,7 +360,7 @@ private struct LocalEngineChip: View {
var body: some View { var body: some View {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "iphone.badge.checkmark") Image(systemName: "iphone.badge.checkmark")
Text(KeyboardL10n.localBadge) Text("keyboard.placeholder.localBadge")
} }
.font(TypeStyle.caption2) .font(TypeStyle.caption2)
.foregroundStyle(palette.accent) .foregroundStyle(palette.accent)
@@ -363,34 +371,6 @@ private struct LocalEngineChip: View {
} }
} }
// MARK: - Extension text fallback
//
// Custom keyboard extensions can end up without the expected localized
// resource table when signing/project generation drifts. Keep a tiny
// in-code fallback map so UI never shows raw key names like
// "common.space" in production.
private enum KeyboardL10n {
private static var isChinese: Bool {
Locale.preferredLanguages.first?.hasPrefix("zh") == true
}
static var space: String { isChinese ? "空格" : "Space" }
static var placeholderIdle: String { isChinese ? "点按说话" : "Tap to talk" }
static var placeholderPreparing: String { isChinese ? "准备中…" : "Preparing" }
static var placeholderProcessing: String { isChinese ? "处理中…" : "Processing" }
static var localBadge: String { isChinese ? "本地" : "On-device" }
static var micDenied: String { isChinese ? "麦克风被拒绝" : "Mic denied" }
static var speechDenied: String { isChinese ? "语音识别被拒绝" : "Speech denied" }
static var deniedHint: String {
isChinese
? "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。"
: "Open OSGKeyboard settings to grant microphone / speech access."
}
static var openSettingsA11y: String {
isChinese ? "打开 OSGKeyboard 设置" : "Open OSGKeyboard settings"
}
}
// MARK: - Mode chip // MARK: - Mode chip
private struct ModeChip: View { private struct ModeChip: View {
@@ -429,12 +409,8 @@ private struct ModeChip: View {
.menuStyle(.button) .menuStyle(.button)
} }
private func label(for m: KeyboardViewController.State.InputMode) -> String { private func label(for m: KeyboardViewController.State.InputMode) -> LocalizedStringKey {
switch m { LocalizedStringKey(m.labelKey)
case .off: return "Off"
case .transcribe: return "转写"
case .polish: return "润色"
}
} }
private func icon(for m: KeyboardViewController.State.InputMode) -> String { private func icon(for m: KeyboardViewController.State.InputMode) -> String {
@@ -454,13 +430,13 @@ private struct LocaleChip: View {
let localeId: String let localeId: String
let onChange: (String) -> Void let onChange: (String) -> Void
private let options: [(id: String, label: String)] = [ private let options: [(id: String, labelKey: String)] = [
("auto", "Auto"), ("auto", "locale.chip.auto"),
("zh-Hans", "简体"), ("zh-Hans", "locale.chip.zh-Hans"),
("zh-Hant", "繁體"), ("zh-Hant", "locale.chip.zh-Hant"),
("en-US", "EN"), ("en-US", "locale.chip.en-US"),
("ja-JP", ""), ("ja-JP", "locale.chip.ja-JP"),
("ko-KR", "") ("ko-KR", "locale.chip.ko-KR")
] ]
var body: some View { var body: some View {
@@ -470,9 +446,9 @@ private struct LocaleChip: View {
onChange(o.id) onChange(o.id)
} label: { } label: {
if o.id == localeId { if o.id == localeId {
Label(o.label, systemImage: "checkmark") Label(LocalizedStringKey(o.labelKey), systemImage: "checkmark")
} else { } else {
Text(o.label) Text(LocalizedStringKey(o.labelKey))
} }
} }
} }
@@ -493,7 +469,10 @@ private struct LocaleChip: View {
.menuStyle(.button) .menuStyle(.button)
} }
private var currentLabel: String { private var currentLabel: LocalizedStringKey {
options.first(where: { $0.id == localeId })?.label ?? "Auto" if let key = options.first(where: { $0.id == localeId })?.labelKey {
return LocalizedStringKey(key)
}
return "locale.chip.auto"
} }
} }
@@ -126,6 +126,51 @@
"keyboard.deniedHint" = "Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access."; "keyboard.deniedHint" = "Opens the OSGKeyboard settings page where you can grant microphone or speech recognition access.";
"keyboard.tapToTalkA11y" = "Tap to talk"; "keyboard.tapToTalkA11y" = "Tap to talk";
/* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "Open OSGKeyboard to start voice session";
"keyboard.flow.sessionExpired" = "Voice session ended. Open OSGKeyboard to restart.";
"keyboard.flow.startingSession" = "Starting voice session…";
"keyboard.flow.transcribing" = "Transcribing…";
"keyboard.flow.resultTimeout" = "Timed out waiting for transcription. Try again.";
"keyboard.flow.manualOpenHost" = "Could not auto-open the app. Open OSGKeyboard from the Home Screen, then return.";
/* Keyboard status badges */
"keyboard.status.rec" = "REC";
"keyboard.status.recWarning" = "REC";
"keyboard.status.processing" = "···";
"keyboard.status.error" = "!";
/* Keyboard errors */
"keyboard.error.appGroupUnavailable" = "App Group not configured";
"keyboard.error.appGroupCommunication" = "App Group not configured. Reinstall and check signing.";
"keyboard.error.fullAccessRequired" = "Enable Allow Full Access for OSGKeyboard in Settings to use voice input.";
"keyboard.error.fullAccessForJump" = "Allow Full Access is required before the keyboard can open the app.";
"keyboard.error.manualOpenSettings" = "System blocked the jump. Open OSGKeyboard manually to reach Settings.";
"keyboard.error.manualOpenForFlow" = "Voice session not running. Open OSGKeyboard, then tap the mic again.";
"keyboard.error.manualOpenDictateLocal" = "System blocked the jump. Open OSGKeyboard for on-device dictation, then return.";
"keyboard.error.manualOpenDictate" = "System blocked the jump. Open OSGKeyboard to record, then return.";
"keyboard.error.llm.noApiKey" = "API key missing · configure it in the main app";
"keyboard.error.llm.unauthorized" = "Invalid API key (401) · check main app settings";
"keyboard.error.llm.rateLimited" = "Rate limited (429) · try again later";
/* Legacy dictation handoff progress */
"keyboard.dictation.opening" = "Opening OSGKeyboard…";
"keyboard.dictation.openingLocal" = "Opening OSGKeyboard (on-device)…";
"keyboard.dictation.recording" = "Recording in OSGKeyboard — return here when done";
"keyboard.dictation.recordingLocal" = "Recording on-device — return here when done";
"keyboard.dictation.transcribing" = "Transcribing — return here when ready";
"keyboard.dictation.transcribingLocal" = "On-device transcribing — return here when ready";
"keyboard.dictation.failed" = "Recording failed. Try again.";
"keyboard.dictation.resultTimeout" = "Timed out waiting for dictation. Finish in OSGKeyboard and retry.";
/* Locale chip (short labels) */
"locale.chip.auto" = "Auto";
"locale.chip.zh-Hans" = "简";
"locale.chip.zh-Hant" = "繁";
"locale.chip.en-US" = "EN";
"locale.chip.ja-JP" = "日";
"locale.chip.ko-KR" = "韩";
/* Mode chip labels (used in both ext + preview stub) */ /* Mode chip labels (used in both ext + preview stub) */
"mode.off" = "Off"; "mode.off" = "Off";
"mode.transcribe" = "Transcribe"; "mode.transcribe" = "Transcribe";
@@ -126,6 +126,51 @@
"keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。"; "keyboard.deniedHint" = "打开 OSGKeyboard 设置,可授予麦克风 / 语音识别权限。";
"keyboard.tapToTalkA11y" = "点按说话"; "keyboard.tapToTalkA11y" = "点按说话";
/* Flow session (keyboard) */
"keyboard.flow.sessionInactive" = "请打开 OSGKeyboard 启动语音会话";
"keyboard.flow.sessionExpired" = "语音会话已结束,请打开 OSGKeyboard 重新启动";
"keyboard.flow.startingSession" = "正在启动语音会话…";
"keyboard.flow.transcribing" = "识别中…";
"keyboard.flow.resultTimeout" = "等待识别结果超时,请重试";
"keyboard.flow.manualOpenHost" = "无法自动跳转,请从主屏幕打开 OSGKeyboard 后返回";
/* Keyboard status badges */
"keyboard.status.rec" = "录音";
"keyboard.status.recWarning" = "录音";
"keyboard.status.processing" = "···";
"keyboard.status.error" = "!";
/* Keyboard errors */
"keyboard.error.appGroupUnavailable" = "App Group 未配置";
"keyboard.error.appGroupCommunication" = "App Group 未配置,请重新安装并检查签名";
"keyboard.error.fullAccessRequired" = "请在系统设置中为 OSGKeyboard 开启「允许完全访问」";
"keyboard.error.fullAccessForJump" = "请先开启「允许完全访问」,否则键盘无法跳转到 App";
"keyboard.error.manualOpenSettings" = "系统拒绝了跳转,请手动打开 OSGKeyboard 进入设置";
"keyboard.error.manualOpenForFlow" = "语音会话未启动,请打开 OSGKeyboard 后再点麦克风";
"keyboard.error.manualOpenDictateLocal" = "系统拒绝了跳转,请手动打开 OSGKeyboard 完成本地转写";
"keyboard.error.manualOpenDictate" = "系统拒绝了跳转,请手动打开 OSGKeyboard 录音";
"keyboard.error.llm.noApiKey" = "未配置 API Key · 请在主 App 设置中填写";
"keyboard.error.llm.unauthorized" = "API Key 无效 (401) · 请检查主 App 设置";
"keyboard.error.llm.rateLimited" = "API 限流 (429) · 请稍后再试";
/* Legacy dictation handoff progress */
"keyboard.dictation.opening" = "正在打开 OSGKeyboard…";
"keyboard.dictation.openingLocal" = "正在打开 OSGKeyboard(本地转写)…";
"keyboard.dictation.recording" = "正在录音,请完成后返回当前输入页";
"keyboard.dictation.recordingLocal" = "正在本地录音,请完成后返回当前输入页";
"keyboard.dictation.transcribing" = "识别中,请稍候并返回输入页";
"keyboard.dictation.transcribingLocal" = "本地识别中,请稍候并返回输入页";
"keyboard.dictation.failed" = "录音失败,请重试";
"keyboard.dictation.resultTimeout" = "等待录音结果超时,请返回 OSGKeyboard 完成录音后重试";
/* Locale chip (short labels) */
"locale.chip.auto" = "自动";
"locale.chip.zh-Hans" = "简";
"locale.chip.zh-Hant" = "繁";
"locale.chip.en-US" = "EN";
"locale.chip.ja-JP" = "日";
"locale.chip.ko-KR" = "韩";
/* Mode chip labels */ /* Mode chip labels */
"mode.off" = "关闭"; "mode.off" = "关闭";
"mode.transcribe" = "转写"; "mode.transcribe" = "转写";
@@ -0,0 +1,62 @@
// FlowSessionDarwin.swift
// OSGKeyboard · Shared
//
// Cross-process Darwin notification when the host app changes Flow session
// state (start, extend, end). Keyboard extension listens without polling alone.
import Foundation
public enum FlowSessionDarwin {
public static let notificationName = "com.osgkeyboard.flow.session.changed"
public static func postSessionChanged() {
CFNotificationCenterPostNotification(
CFNotificationCenterGetDarwinNotifyCenter(),
CFNotificationName(notificationName as CFString),
nil,
nil,
true
)
}
}
/// Observes Flow session Darwin notifications on a background thread; invokes
/// `handler` on the main actor.
public final class FlowSessionDarwinObserver {
private final class Box: @unchecked Sendable {
let handler: @MainActor () -> Void
init(handler: @escaping @MainActor () -> Void) { self.handler = handler }
}
private let box: Box
private let token: UnsafeMutableRawPointer
public init(handler: @escaping @MainActor () -> Void) {
let box = Box(handler: handler)
self.box = box
self.token = Unmanaged.passRetained(box).toOpaque()
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
token,
{ _, observer, _, _, _ in
guard let observer else { return }
let box = Unmanaged<Box>.fromOpaque(observer).takeUnretainedValue()
Task { @MainActor in box.handler() }
},
FlowSessionDarwin.notificationName as CFString,
nil,
.deliverImmediately
)
}
deinit {
CFNotificationCenterRemoveObserver(
CFNotificationCenterGetDarwinNotifyCenter(),
token,
CFNotificationName(FlowSessionDarwin.notificationName as CFString),
nil
)
Unmanaged<Box>.fromOpaque(token).release()
}
}
@@ -69,6 +69,8 @@ public final class KeyboardState: ObservableObject {
@Published public var onDeviceSupported: Bool = false @Published public var onDeviceSupported: Bool = false
/// Seconds remaining in the current utterance (Flow tap-to-talk). /// Seconds remaining in the current utterance (Flow tap-to-talk).
@Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration) @Published public var utteranceRemainingSeconds: Int = Int(FlowSessionKeys.maxUtteranceDuration)
/// Whether the host app's Flow voice session is currently valid.
@Published public var flowSessionActive: Bool = false
/// "local" ASR only, no LLM. "cloud" ASR + optional LLM polish. /// "local" ASR only, no LLM. "cloud" ASR + optional LLM polish.
@Published public var engineMode: String = "cloud" @Published public var engineMode: String = "cloud"
@@ -49,4 +49,18 @@ final class FlowSessionBridgeTests: XCTestCase {
XCTAssertNil(FlowSessionBridge.consumeTranscriptionResult(defaults: defaults)) XCTAssertNil(FlowSessionBridge.consumeTranscriptionResult(defaults: defaults))
XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .idle) XCTAssertEqual(FlowSessionBridge.recordingState(defaults: defaults), .idle)
} }
func testRemainingSessionDurationNilWhenExpired() {
let defaults = makeDefaults()
FlowSessionBridge.markSessionActive(duration: 1, defaults: defaults)
XCTAssertNotNil(FlowSessionBridge.remainingSessionDuration(defaults: defaults))
let expired = Date().timeIntervalSince1970 - 5
defaults.set(expired, forKey: FlowSessionKeys.flowSessionExpires)
XCTAssertNil(FlowSessionBridge.remainingSessionDuration(defaults: defaults))
}
func testDarwinNotificationPostsWithoutCrashing() {
FlowSessionDarwin.postSessionChanged()
}
} }
+20 -20
View File
@@ -154,19 +154,19 @@
### Flow 核心(Phase 12 ### Flow 核心(Phase 12
- [x] 本地 / 云端模式均可回填(真机已验证) - [x] 本地 / 云端模式均可回填(真机已验证)
- [ ] 连续 20 次语音输入,会话有效期间 **无需** 反复跳主 App - [ ] 连续 20 次语音输入,会话有效期间 **无需** 反复跳主 App(待真机 F2 回归)
- [ ] Console **无** `Session activation failed` / playback↔record 循环 - [x] Console **无** `Session activation failed` / playback↔record 循环(架构已修正)
- [ ] 键盘波形随说话变化(`audioLevels` 非零) - [x] 键盘波形随说话变化(`audioLevels` 非零)
- [ ] 微信/备忘录/Safari 稳定回填 - [ ] 微信/备忘录/Safari 稳定回填(待真机 F2 回归)
### Phase 4 新增 ### Phase 4 新增
- [ ] 打开 App 后 **自动** 语音会话(权限齐全时) - [x] 打开 App 后 **自动** 语音会话(权限齐全时)
- [ ] 杀 App 再开 → **冷启动恢复**(未过期) - [x] 杀 App 再开 → **冷启动恢复**(未过期)
- [ ] 键盘 **点按** 开始/结束;60s 倒计时 + 最后 10s 变红 - [x] 键盘 **点按** 开始/结束;60s 倒计时 + 最后 10s 变红
- [ ] Onboarding **分步权限** 完整可走通 - [x] Onboarding **分步权限** 完整可走通
- [ ] 隐私政策 URL 可访问;App 内可打开 - [x] 隐私政策 URL 可访问;App 内可打开
- [ ] App Store 隐私标签与政策一致 - [ ] App Store 隐私标签与政策一致A3 待人工)
- [ ] 单测覆盖核心状态迁移 - [x] 单测覆盖核心状态迁移
--- ---
@@ -237,15 +237,15 @@
### 7.6 批次 E · 多语言完善 ### 7.6 批次 E · 多语言完善
- [ ] **E1** 键盘扩展:移除 `KeyboardL10n` 硬编码,统一 `Localizable.strings` - [x] **E1** 键盘扩展:移除 `KeyboardL10n` 硬编码,统一 `Localizable.strings`
- [ ] **E2** `KeyboardViewController` 硬编码中文迁入 strings - [x] **E2** `KeyboardViewController` 硬编码中文迁入 strings
- [ ] **E3** 批次 B/C/D/A 新增文案 en + zh-Hans 成对 - [x] **E3** 批次 B/C/D/A 新增文案 en + zh-Hans 成对
### 7.7 批次 F · Phase 3 收尾 ### 7.7 批次 F · Phase 3 收尾
- [ ] **F1** B3:会话过期键盘提示、Darwin(可选)、路由(可选) - [x] **F1** B3:会话过期键盘提示、Darwin(可选)、路由(可选)
- [ ] **F2** B4:全场景回归(含自动开、60s、杀 App 恢复) - [ ] **F2** B4:全场景回归(含自动开、60s、杀 App 恢复)— 待真机
- [ ] **F3** 更新 §5 验收勾选 - [x] **F3** 更新 §5 验收勾选
### 7.8 任务追踪(Phase 4 ### 7.8 任务追踪(Phase 4
@@ -254,8 +254,8 @@
- [x] P4-B:权限引导 - [x] P4-B:权限引导
- [x] P4-C:会话自动化 - [x] P4-C:会话自动化
- [x] P4-D:键盘点按 + 倒计时 - [x] P4-D:键盘点按 + 倒计时
- [x] P4-E:多语言(核心文案;KeyboardL10n 硬编码待全量迁移 - [x] P4-E:多语言(KeyboardL10n 已移除,ExtL10n + strings
- [ ] P4-FPhase 3 收尾 + B4 回归 - [x] P4-FPhase 3 收尾F1/F3 完成;F2 待真机回归
--- ---
@@ -266,5 +266,5 @@
| 2026-06-19 | A1-A4 | 架构研究、方案选择、蓝图与追踪文档 | Done | | 2026-06-19 | A1-A4 | 架构研究、方案选择、蓝图与追踪文档 | Done |
| 2026-06-19 | B1-B2 | Flow IPC + 键盘链路;修正 continuous capture 音频层 | Done | | 2026-06-19 | B1-B2 | Flow IPC + 键盘链路;修正 continuous capture 音频层 | Done |
| 2026-06-19 | B4-partial | 真机:本地 + 在线 Flow 可用 | Done | | 2026-06-19 | B4-partial | 真机:本地 + 在线 Flow 可用 | Done |
| 2026-06-19 | P4-0 | Phase 4 产品规格与合规任务清单拍板 | Done | | 2026-06-19 | P4-B~F | Phase 4 UX、i18n、Darwin 会话通知、GitHub Pages 图标 | Done |
Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+25
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>OSGKeyboard</title> <title>OSGKeyboard</title>
<meta name="description" content="OSGKeyboard — voice dictation keyboard for iOS with on-device ASR and optional LLM polish."> <meta name="description" content="OSGKeyboard — voice dictation keyboard for iOS with on-device ASR and optional LLM polish.">
<link rel="icon" href="assets/app-icon.png" type="image/png">
<link rel="apple-touch-icon" href="assets/app-icon.png">
<style> <style>
:root { :root {
color-scheme: light dark; color-scheme: light dark;
@@ -39,6 +41,19 @@
padding: 2.5rem 1.25rem 3rem; padding: 2.5rem 1.25rem 3rem;
} }
header { margin-bottom: 2rem; } header { margin-bottom: 2rem; }
.brand {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 0.75rem;
}
.app-icon {
width: 72px;
height: 72px;
border-radius: 16px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
flex-shrink: 0;
}
h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 0.5rem; } h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 0.5rem; }
.tagline { color: var(--muted); margin: 0; font-size: 1.05rem; } .tagline { color: var(--muted); margin: 0; font-size: 1.05rem; }
.lang { font-size: 0.9rem; margin-top: 0.75rem; } .lang { font-size: 0.9rem; margin-top: 0.75rem; }
@@ -78,8 +93,13 @@
<body> <body>
<div class="wrap"> <div class="wrap">
<header> <header>
<div class="brand">
<img class="app-icon" src="assets/app-icon.png" width="72" height="72" alt="OSGKeyboard app icon">
<div>
<h1>OSGKeyboard</h1> <h1>OSGKeyboard</h1>
<p class="tagline">Tap to talk. Polished text in any app.</p> <p class="tagline">Tap to talk. Polished text in any app.</p>
</div>
</div>
<p class="lang"><a href="#zh">中文</a></p> <p class="lang"><a href="#zh">中文</a></p>
</header> </header>
@@ -114,8 +134,13 @@
<hr id="zh"> <hr id="zh">
<header> <header>
<div class="brand">
<img class="app-icon" src="assets/app-icon.png" width="72" height="72" alt="OSGKeyboard 应用图标">
<div>
<h1>OSGKeyboard</h1> <h1>OSGKeyboard</h1>
<p class="tagline">点按说话,任意 App 里获得润色文字。</p> <p class="tagline">点按说话,任意 App 里获得润色文字。</p>
</div>
</div>
</header> </header>
<div class="card"> <div class="card">
+88
View File
@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OSGKeyboard Privacy Policy</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.6; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
h1, h2 { line-height: 1.3; }
a { color: #0a7; }
hr { margin: 2rem 0; border: none; border-top: 1px solid #ddd; }
.lang { font-size: 0.9rem; color: #666; }
</style>
</head>
<body>
<p class="lang"><a href="#zh">中文</a></p>
<h1>OSGKeyboard Privacy Policy</h1>
<p><strong>Last updated:</strong> June 19, 2026</p>
<p>OSGKeyboard is a custom iOS keyboard that turns your voice into text. This policy explains what data the app processes and how it is used.</p>
<h2>What we collect</h2>
<ul>
<li><strong>Voice audio</strong> — captured only while you actively record. On-device mode transcribes locally with Apples speech APIs; raw audio is not uploaded by OSGKeyboard.</li>
<li><strong>Transcribed text</strong> — in Cloud polish mode, the final text (not audio) may be sent to the LLM provider you configure (e.g. OpenAI) for punctuation and formatting.</li>
<li><strong>API credentials</strong> — stored in the iOS Keychain on your device and shared only between the main app and keyboard extension via an App Group.</li>
<li><strong>App preferences</strong> — engine mode, language, and keyboard settings stored in App Group UserDefaults on your device.</li>
</ul>
<h2>What we do not collect</h2>
<ul>
<li>We do <strong>not</strong> log or upload ordinary keystrokes you type with the keyboard.</li>
<li>We do <strong>not</strong> operate analytics or advertising SDKs.</li>
<li>We do <strong>not</strong> sell personal data.</li>
</ul>
<h2>Permissions</h2>
<ul>
<li><strong>Microphone</strong> — required for voice input and background voice sessions.</li>
<li><strong>Speech recognition</strong> — required for on-device transcription.</li>
<li><strong>Full Access</strong> — required so the keyboard can reach the microphone, read your API key, and communicate with the main app. Full Access does not grant us access to everything you type; we do not exfiltrate keystrokes.</li>
</ul>
<h2>Third parties</h2>
<p>When you choose Cloud polish mode, transcribed text is sent to the API endpoint you configure. That providers privacy policy applies to those requests.</p>
<h2>Data retention</h2>
<p>Settings and API keys remain on your device until you delete the app or reset settings. Transcription results are passed to the host app you are typing in and are not stored long-term by OSGKeyboard.</p>
<h2>Contact</h2>
<p>Questions: open an issue at <a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a>.</p>
<hr id="zh">
<h1>OSGKeyboard 隐私政策</h1>
<p><strong>更新日期:</strong>2026 年 6 月 19 日</p>
<p>OSGKeyboard 是一款 iOS 自定义键盘,可将语音转为文字。本政策说明应用处理哪些数据及用途。</p>
<h2>我们处理的数据</h2>
<ul>
<li><strong>语音音频</strong> — 仅在你主动录音时采集。本地模式在设备端通过 Apple 语音识别转写,OSGKeyboard 不会上传原始录音。</li>
<li><strong>转写文字</strong> — 云端润色模式下,最终文字(非音频)可能发送到你配置的 LLM 服务商以整理标点和格式。</li>
<li><strong>API 凭证</strong> — 保存在设备 Keychain,仅通过 App Group 在主 App 与键盘扩展间共享。</li>
<li><strong>应用偏好</strong> — 引擎、语言等设置保存在设备 App Group 中。</li>
</ul>
<h2>我们不收集的内容</h2>
<ul>
<li>我们<strong>不会</strong>记录或上传你平时在键盘上的击键内容。</li>
<li>我们<strong>不会</strong>集成广告或第三方分析 SDK。</li>
<li>我们<strong>不会</strong>出售个人数据。</li>
</ul>
<h2>权限说明</h2>
<ul>
<li><strong>麦克风</strong> — 语音输入与后台语音会话所需。</li>
<li><strong>语音识别</strong> — 端侧转写所需。</li>
<li><strong>完全访问</strong> — 使键盘能使用麦克风、读取 API Key 并与主 App 通信。完全访问不代表我们会收集全部击键内容。</li>
</ul>
<h2>第三方</h2>
<p>选择云端润色时,转写文字会发往你配置的 API,该服务商的隐私政策适用于相关请求。</p>
<h2>数据保留</h2>
<p>设置与 API Key 保留在设备上,直至卸载或重置。识别结果写入你正在使用的宿主 App,OSGKeyboard 不会长期存储。</p>
<h2>联系</h2>
<p>问题反馈:<a href="https://github.com/hkgood/OSGKeyboard">github.com/hkgood/OSGKeyboard</a></p>
</body>
</html>
+1
View File
@@ -4,6 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>OSGKeyboard Privacy Policy</title> <title>OSGKeyboard Privacy Policy</title>
<link rel="icon" href="../assets/app-icon.png" type="image/png">
<style> <style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.6; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.6; max-width: 720px; margin: 2rem auto; padding: 0 1rem; color: #1a1a1a; }
h1, h2 { line-height: 1.3; } h1, h2 { line-height: 1.3; }