From 02f6ff8d752ae1d23a40eb20232faba2de45a261 Mon Sep 17 00:00:00 2001 From: idevlab Date: Sun, 30 Aug 2026 15:53:37 +0800 Subject: [PATCH 1/2] feat: align settings UI with macOS --- Sources/App/OpenTypeApp.swift | 9 +- Sources/Config/AppSettings.swift | 2 +- .../Resources/en.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + Sources/UI/AboutView.swift | 80 ++---- Sources/UI/DictionaryManagementView.swift | 30 +- Sources/UI/DictionaryStyleView.swift | 264 +++++------------- Sources/UI/GeneralSettingsView.swift | 34 +-- Sources/UI/HistoryInsightsOverview.swift | 19 +- Sources/UI/HistoryRecordsView.swift | 23 +- Sources/UI/HistoryStatsView.swift | 41 +-- Sources/UI/IndustryLexiconView.swift | 33 +-- Sources/UI/IntegrationsSettingsView.swift | 107 ++++--- Sources/UI/ModelManagementActions.swift | 9 +- Sources/UI/ModelManagementEnginePicker.swift | 71 +---- Sources/UI/ModelManagementFamilies.swift | 125 +++++---- Sources/UI/ModelManagementRows.swift | 10 +- Sources/UI/ModelManagementSections.swift | 68 ++--- Sources/UI/ModelManagementView.swift | 47 ++-- Sources/UI/SettingsView.swift | 15 +- Sources/UI/SettingsVoiceIllustration.swift | 159 +---------- Tests/OpenTypeTests/ConfigurationTests.swift | 22 +- .../2026-08-30-history-mode-switch/intent.md | 41 +++ .../2026-08-30-history-mode-switch/plan.md | 24 ++ .../2026-08-30-history-mode-switch/spec.md | 52 ++++ .../2026-08-30-history-mode-switch/state.json | 27 ++ .../verification.md | 31 ++ .../2026-08-30-model-type-selection/intent.md | 41 +++ .../2026-08-30-model-type-selection/plan.md | 29 ++ .../2026-08-30-model-type-selection/spec.md | 54 ++++ .../state.json | 33 +++ .../verification.md | 48 ++++ .../intent.md | 49 ++++ .../2026-08-30-settings-window-layout/plan.md | 27 ++ .../2026-08-30-settings-window-layout/spec.md | 47 ++++ .../state.json | 35 +++ .../verification.md | 34 +++ .../intent.md | 46 +++ .../2026-08-30-system-settings-groups/plan.md | 25 ++ .../2026-08-30-system-settings-groups/spec.md | 59 ++++ .../state.json | 40 +++ .../verification.md | 33 +++ 42 files changed, 1163 insertions(+), 782 deletions(-) create mode 100644 docs/sdlc/changes/2026-08-30-history-mode-switch/intent.md create mode 100644 docs/sdlc/changes/2026-08-30-history-mode-switch/plan.md create mode 100644 docs/sdlc/changes/2026-08-30-history-mode-switch/spec.md create mode 100644 docs/sdlc/changes/2026-08-30-history-mode-switch/state.json create mode 100644 docs/sdlc/changes/2026-08-30-history-mode-switch/verification.md create mode 100644 docs/sdlc/changes/2026-08-30-model-type-selection/intent.md create mode 100644 docs/sdlc/changes/2026-08-30-model-type-selection/plan.md create mode 100644 docs/sdlc/changes/2026-08-30-model-type-selection/spec.md create mode 100644 docs/sdlc/changes/2026-08-30-model-type-selection/state.json create mode 100644 docs/sdlc/changes/2026-08-30-model-type-selection/verification.md create mode 100644 docs/sdlc/changes/2026-08-30-settings-window-layout/intent.md create mode 100644 docs/sdlc/changes/2026-08-30-settings-window-layout/plan.md create mode 100644 docs/sdlc/changes/2026-08-30-settings-window-layout/spec.md create mode 100644 docs/sdlc/changes/2026-08-30-settings-window-layout/state.json create mode 100644 docs/sdlc/changes/2026-08-30-settings-window-layout/verification.md create mode 100644 docs/sdlc/changes/2026-08-30-system-settings-groups/intent.md create mode 100644 docs/sdlc/changes/2026-08-30-system-settings-groups/plan.md create mode 100644 docs/sdlc/changes/2026-08-30-system-settings-groups/spec.md create mode 100644 docs/sdlc/changes/2026-08-30-system-settings-groups/state.json create mode 100644 docs/sdlc/changes/2026-08-30-system-settings-groups/verification.md diff --git a/Sources/App/OpenTypeApp.swift b/Sources/App/OpenTypeApp.swift index a34f3fe3..8931dde2 100644 --- a/Sources/App/OpenTypeApp.swift +++ b/Sources/App/OpenTypeApp.swift @@ -214,16 +214,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { width: SettingsWindowLayout.width, height: SettingsWindowLayout.height ), - styleMask: [.titled, .closable, .miniaturizable, .resizable], + styleMask: SettingsWindowLayout.styleMask, backing: .buffered, defer: false ) window.title = SettingsWindowTitle.current - window.minSize = NSSize( - width: SettingsWindowLayout.minimumWidth, - height: SettingsWindowLayout.minimumHeight - ) window.setFrameAutosaveName("UtterSettingsWindow") + window.contentMinSize = SettingsWindowLayout.contentSize + window.contentMaxSize = SettingsWindowLayout.contentSize + window.setContentSize(SettingsWindowLayout.contentSize) window.center() window.contentView = NSHostingView(rootView: settingsView) window.isReleasedWhenClosed = false diff --git a/Sources/Config/AppSettings.swift b/Sources/Config/AppSettings.swift index 69e6cb86..691d7595 100644 --- a/Sources/Config/AppSettings.swift +++ b/Sources/Config/AppSettings.swift @@ -36,7 +36,7 @@ enum SpeechEngineType: String, Codable, CaseIterable { case mimo = "mimo" static var selectableCases: [SpeechEngineType] { - allCases.filter { $0 != .mimo } + [.qwen3, .whisper, .apple, .volc] } var label: String { diff --git a/Sources/Resources/en.lproj/Localizable.strings b/Sources/Resources/en.lproj/Localizable.strings index a4b09b8f..2774114b 100644 --- a/Sources/Resources/en.lproj/Localizable.strings +++ b/Sources/Resources/en.lproj/Localizable.strings @@ -15,6 +15,7 @@ "common.copy_clipboard" = "Copy to Clipboard"; "common.cannot_undo" = "This action cannot be undone."; "common.recommended" = "Recommended"; +"common.recommended_short" = "Rec."; "common.custom" = "Custom"; "common.retry" = "Retry"; diff --git a/Sources/Resources/zh-Hans.lproj/Localizable.strings b/Sources/Resources/zh-Hans.lproj/Localizable.strings index d0ec2353..a3b4eef9 100644 --- a/Sources/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/Resources/zh-Hans.lproj/Localizable.strings @@ -15,6 +15,7 @@ "common.copy_clipboard" = "复制到剪贴板"; "common.cannot_undo" = "此操作不可撤销。"; "common.recommended" = "推荐"; +"common.recommended_short" = "推荐"; "common.custom" = "自定义"; "common.retry" = "重试"; diff --git a/Sources/UI/AboutView.swift b/Sources/UI/AboutView.swift index 5d0b776d..32b18288 100644 --- a/Sources/UI/AboutView.swift +++ b/Sources/UI/AboutView.swift @@ -4,25 +4,38 @@ import Speech struct AboutView: View { var body: some View { - VStack(spacing: 0) { - SettingsPageHeader( - kind: .about, - title: ProductBrand.displayName, - subtitle: L("settings.page.about.subtitle") - ) { - SettingsPageBadge(title: version, symbol: "shippingbox") + Form { + Section { + permRow(icon: "hand.raised.fill", name: L("perm.accessibility"), + hint: L("perm.accessibility_hint"), granted: accessibilityGranted, action: openAccessibility) + permRow(icon: "mic.fill", name: L("perm.microphone"), + hint: L("perm.microphone_hint"), granted: microphoneGranted, action: requestMicrophone) + permRow(icon: "waveform", name: L("perm.speech"), + hint: L("perm.speech_hint"), granted: speechGranted, action: requestSpeech) + permRow(icon: "rectangle.dashed.badge.record", name: L("perm.screen"), + hint: L("perm.screen_hint"), granted: screenCaptureGranted, action: requestScreenCapture) + } header: { + HStack { + Text(L("settings.permissions")) + Spacer() + Button { + checkAll() + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.plain) + .accessibilityLabel(L("settings.permissions")) + } } - Divider() - ScrollView { - VStack(spacing: 14) { - SettingsPanel { permissionsSection } - SettingsPanel { appInfo } - } - .padding(20) + Section { + appInfo } } + .formStyle(.grouped) + .scrollContentBackground(.hidden) .settingsPageSurface() + .onAppear { checkAll() } } // MARK: - App Info @@ -65,43 +78,6 @@ struct AboutView: View { @State private var speechGranted = false @State private var screenCaptureGranted = false - private var permissionsSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Label(L("settings.permissions"), systemImage: "lock.shield") - .font(.headline) - Spacer() - Button { - checkAll() - } label: { - Image(systemName: "arrow.clockwise") - .font(.system(size: 11)) - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - } - - VStack(spacing: 0) { - permRow(icon: "hand.raised.fill", name: L("perm.accessibility"), - hint: L("perm.accessibility_hint"), granted: accessibilityGranted, action: openAccessibility) - Divider().padding(.horizontal, 12) - permRow(icon: "mic.fill", name: L("perm.microphone"), - hint: L("perm.microphone_hint"), granted: microphoneGranted, action: requestMicrophone) - Divider().padding(.horizontal, 12) - permRow(icon: "waveform", name: L("perm.speech"), - hint: L("perm.speech_hint"), granted: speechGranted, action: requestSpeech) - Divider().padding(.horizontal, 12) - permRow(icon: "rectangle.dashed.badge.record", name: L("perm.screen"), - hint: L("perm.screen_hint"), granted: screenCaptureGranted, action: requestScreenCapture) - } - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)) - - } - .onAppear { checkAll() } - } - private func permRow(icon: String, name: String, hint: String, granted: Bool, action: @escaping () -> Void) -> some View { HStack(spacing: 10) { Image(systemName: icon) @@ -125,8 +101,6 @@ struct AboutView: View { .controlSize(.mini) } } - .padding(.horizontal, 12) - .padding(.vertical, 8) } // MARK: - Permission actions diff --git a/Sources/UI/DictionaryManagementView.swift b/Sources/UI/DictionaryManagementView.swift index 4a634b42..0c0d3703 100644 --- a/Sources/UI/DictionaryManagementView.swift +++ b/Sources/UI/DictionaryManagementView.swift @@ -19,12 +19,16 @@ struct DictionaryManagementView: View { @State private var showClearConfirmation = false var body: some View { - VStack(alignment: .leading, spacing: 10) { - header + Section { + Toggle(L("dictionary.auto_learning"), isOn: $settings.enableCorrectionLearning) addRow filterRow entriesList footer + } header: { + Text(L("dictionary.title")) + } footer: { + Text(L("dictionary.subtitle")) } .alert(L("dictionary.clear_learned_confirm"), isPresented: $showClearConfirmation) { Button(L("common.cancel"), role: .cancel) {} @@ -36,22 +40,6 @@ struct DictionaryManagementView: View { } } - private var header: some View { - VStack(alignment: .leading, spacing: 5) { - HStack { - Label(L("dictionary.title"), systemImage: "text.book.closed") - .font(.headline) - Spacer() - Toggle(L("dictionary.auto_learning"), isOn: $settings.enableCorrectionLearning) - .toggleStyle(.switch) - .controlSize(.small) - } - Text(L("dictionary.subtitle")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - private var addRow: some View { HStack(spacing: 8) { TextField(L("dictionary.spoken_form"), text: $newOriginal) @@ -106,12 +94,6 @@ struct DictionaryManagementView: View { } } .frame(minHeight: 110, maxHeight: 190) - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - } } } } diff --git a/Sources/UI/DictionaryStyleView.swift b/Sources/UI/DictionaryStyleView.swift index c066d0fc..e1e47c91 100644 --- a/Sources/UI/DictionaryStyleView.swift +++ b/Sources/UI/DictionaryStyleView.swift @@ -3,148 +3,69 @@ import SwiftUI struct DictionaryStyleView: View { @EnvironmentObject var settings: AppSettings @StateObject private var dictionary = PersonalDictionary.shared - @State private var newRule = "" var body: some View { - VStack(spacing: 0) { - SettingsPageHeader( - kind: .style, - title: L("settings.page.style.title"), - subtitle: L("settings.page.style.subtitle") - ) { - SettingsPageBadge(title: settings.languageStyle.label, symbol: settings.languageStyle.icon) - } - Divider() - - ScrollView { - VStack(alignment: .leading, spacing: 14) { - if !settings.useCustomSystemPrompt { - SettingsPanel { styleSection } - } - SettingsPanel { IndustryLexiconView() } - SettingsPanel { DictionaryManagementView() } - SettingsPanel { editRulesSection } - SettingsPanel { customSystemPromptSection } - } - .padding(20) + Form { + if !settings.useCustomSystemPrompt { + styleSection } + IndustryLexiconView() + DictionaryManagementView() + editRulesSection + customSystemPromptSection } + .formStyle(.grouped) + .scrollContentBackground(.hidden) .settingsPageSurface() } - // MARK: - Custom System Prompt - - private var customSystemPromptSection: some View { - VStack(alignment: .leading, spacing: 10) { - HStack { - Label(L("custom_prompt.title"), systemImage: "terminal") - .font(.headline) - Spacer() - Toggle("", isOn: $settings.useCustomSystemPrompt) - .toggleStyle(.switch) - .controlSize(.small) - .labelsHidden() - } - - Text(L("custom_prompt.desc")) - .font(.caption) - .foregroundStyle(.secondary) - - if settings.useCustomSystemPrompt { - TextEditor(text: $settings.customSystemPrompt) - .font(.system(size: 11.5, design: .monospaced)) - .scrollContentBackground(.hidden) - .padding(8) - .frame(minHeight: 140, maxHeight: 280) - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - - Text(L("custom_prompt.hint")) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } - } - - // MARK: - Style - private var styleSection: some View { - VStack(alignment: .leading, spacing: 12) { - Label(L("style.title"), systemImage: "paintbrush") - .font(.headline) - - HStack(spacing: 10) { + Section { + Picker(L("style.title"), selection: $settings.languageStyle) { ForEach(LanguageStyle.allCases, id: \.self) { style in - StylePresetCard( - style: style, - isSelected: settings.languageStyle == style - ) { - settings.languageStyle = style - if style.usesCustomPrompt, - settings.customStylePrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || LanguageStyle.looksLikePresetPrompt(settings.customStylePrompt) { - settings.customStylePrompt = style.defaultPrompt - } - } + Text(style.label).tag(style) } } + .pickerStyle(.segmented) + .labelsHidden() + .onChange(of: settings.languageStyle) { _, style in + guard style.usesCustomPrompt, + settings.customStylePrompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || LanguageStyle.looksLikePresetPrompt(settings.customStylePrompt) else { return } + settings.customStylePrompt = style.defaultPrompt + } - if settings.languageStyle.usesCustomPrompt { - VStack(alignment: .leading, spacing: 6) { - Text(L("style.prompt")) - .font(.subheadline.weight(.medium)) + VStack(alignment: .leading, spacing: 6) { + Text(L("style.prompt")) + .font(.subheadline.weight(.medium)) + + if settings.languageStyle.usesCustomPrompt { TextEditor(text: $settings.customStylePrompt) - .font(.system(size: 11.5, design: .monospaced)) + .font(.system(.caption, design: .monospaced)) .scrollContentBackground(.hidden) - .padding(8) - .frame(height: 92) - .background(Color(nsColor: .controlBackgroundColor)) + .padding(6) + .frame(height: 88) + .background(Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - Text(L("style.prompt_help")) - .font(.caption2) - .foregroundStyle(.tertiary) - } - } else { - VStack(alignment: .leading, spacing: 6) { - Text(L("style.prompt")) - .font(.subheadline.weight(.medium)) + } else { Text(settings.languageStyle.defaultPrompt) - .font(.system(size: 11.5)) + .font(.caption) .foregroundStyle(.secondary) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - Text(L("style.preset_help")) - .font(.caption2) - .foregroundStyle(.tertiary) + .fixedSize(horizontal: false, vertical: true) } } + } header: { + Text(L("style.title")) + } footer: { + Text(settings.languageStyle.usesCustomPrompt + ? L("style.prompt_help") + : L("style.preset_help")) } } - // MARK: - Edit Rules - private var editRulesSection: some View { - VStack(alignment: .leading, spacing: 10) { - Label(L("rules.title"), systemImage: "list.bullet.rectangle") - .font(.headline) - Text(L("rules.subtitle")) - .font(.caption) - .foregroundStyle(.secondary) - + Section { HStack(spacing: 8) { TextField(L("rules.placeholder"), text: $newRule) .textFieldStyle(.roundedBorder) @@ -159,34 +80,53 @@ struct DictionaryStyleView: View { if dictionary.editRules.isEmpty { emptyHint(L("rules.empty")) } else { - VStack(spacing: 0) { - ForEach(Array(dictionary.editRules.enumerated()), id: \.element.id) { index, rule in - HStack { - Image(systemName: rule.enabled ? "checkmark.circle.fill" : "circle") - .foregroundStyle(rule.enabled ? .green : .secondary) - .font(.caption) - Text(rule.description) - .font(.system(size: 12)) - .frame(maxWidth: .infinity, alignment: .leading) - deleteButton { dictionary.removeRule(at: IndexSet(integer: index)) } - } - .padding(.vertical, 6) - .padding(.horizontal, 10) - if index < dictionary.editRules.count - 1 { Divider().padding(.horizontal, 10) } + ForEach(Array(dictionary.editRules.enumerated()), id: \.element.id) { index, rule in + HStack { + Image(systemName: rule.enabled ? "checkmark.circle.fill" : "circle") + .foregroundStyle(rule.enabled ? .green : .secondary) + .font(.caption) + Text(rule.description) + .frame(maxWidth: .infinity, alignment: .leading) + deleteButton { dictionary.removeRule(at: IndexSet(integer: index)) } } } - .listCard() } + } header: { + Text(L("rules.title")) + } footer: { + Text(L("rules.subtitle")) } } - // MARK: - Helpers + private var customSystemPromptSection: some View { + Section { + Toggle(isOn: $settings.useCustomSystemPrompt) { + Text(L("custom_prompt.desc")) + } + + if settings.useCustomSystemPrompt { + TextEditor(text: $settings.customSystemPrompt) + .font(.system(.caption, design: .monospaced)) + .scrollContentBackground(.hidden) + .padding(6) + .frame(minHeight: 130, maxHeight: 260) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } header: { + Text(L("custom_prompt.title")) + } footer: { + if settings.useCustomSystemPrompt { + Text(L("custom_prompt.hint")) + } + } + } private func emptyHint(_ text: String) -> some View { Text(text) .font(.caption) .foregroundStyle(.tertiary) - .frame(maxWidth: .infinity, minHeight: 36) + .frame(maxWidth: .infinity, minHeight: 28) } private func deleteButton(action: @escaping () -> Void) -> some View { @@ -196,58 +136,6 @@ struct DictionaryStyleView: View { } .buttonStyle(.plain) .foregroundStyle(.secondary) - } -} - -// MARK: - Style Preset Card - -private struct StylePresetCard: View { - let style: LanguageStyle - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 4) { - Image(systemName: style.icon) - .font(.system(size: 13)) - Text(style.label) - .font(.system(size: 12, weight: .semibold)) - } - Text(style.defaultPrompt) - .font(.system(size: 10)) - .foregroundStyle(.secondary) - .lineLimit(2) - .multilineTextAlignment(.leading) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(isSelected ? Color.accentColor.opacity(0.08) : Color(nsColor: .controlBackgroundColor)) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isSelected ? Color.accentColor : Color(nsColor: .separatorColor), - lineWidth: isSelected ? 1.5 : 0.5) - ) - } - .buttonStyle(.plain) - } -} - -// MARK: - List Card Modifier - -private struct ListCardModifier: ViewModifier { - func body(content: Content) -> some View { - content - .background(SettingsCardBackground(cornerRadius: 8)) - } -} - -private extension View { - func listCard() -> some View { - modifier(ListCardModifier()) + .accessibilityLabel(L("common.delete")) } } diff --git a/Sources/UI/GeneralSettingsView.swift b/Sources/UI/GeneralSettingsView.swift index 7df50e6b..7a5842cc 100644 --- a/Sources/UI/GeneralSettingsView.swift +++ b/Sources/UI/GeneralSettingsView.swift @@ -9,20 +9,7 @@ struct GeneralSettingsView: View { @State private var showsLaunchAtLoginError = false var body: some View { - VStack(spacing: 0) { - SettingsPageHeader( - kind: .general, - title: L("settings.page.general.title"), - subtitle: L("settings.page.general.subtitle") - ) { - SettingsPageBadge( - title: "\(settings.hotkeyType.rawValue) · \(settings.activationMode.label)", - symbol: "keyboard" - ) - } - Divider() - settingsForm - } + settingsForm .settingsPageSurface() .onAppear(perform: refreshLaunchAtLoginStatus) .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in @@ -54,7 +41,7 @@ struct GeneralSettingsView: View { } } } header: { - SettingsSectionHeader(title: L("settings.activation"), symbol: "keyboard.badge.ellipsis") + SettingsSectionHeader(title: L("settings.activation")) } footer: { Text(L("settings.activation_help")) } @@ -73,7 +60,7 @@ struct GeneralSettingsView: View { } } } header: { - SettingsSectionHeader(title: L("settings.audio"), symbol: "waveform") + SettingsSectionHeader(title: L("settings.audio")) } Section { @@ -90,7 +77,7 @@ struct GeneralSettingsView: View { } .disabled(settings.outputMode != .processed) } header: { - SettingsSectionHeader(title: L("settings.output"), symbol: "text.badge.checkmark") + SettingsSectionHeader(title: L("settings.output")) } Section { @@ -113,7 +100,7 @@ struct GeneralSettingsView: View { .fixedSize() } } header: { - SettingsSectionHeader(title: L("settings.translation"), symbol: "character.bubble") + SettingsSectionHeader(title: L("settings.translation")) } footer: { Text(L("settings.translation_shortcut_help")) } @@ -131,7 +118,7 @@ struct GeneralSettingsView: View { .disabled(!settings.useScreenContext) Toggle(L("settings.sound_cues"), isOn: $settings.playSounds) } header: { - SettingsSectionHeader(title: L("settings.context_feedback"), symbol: "rectangle.and.text.magnifyingglass") + SettingsSectionHeader(title: L("settings.context_feedback")) } Section { @@ -143,7 +130,7 @@ struct GeneralSettingsView: View { } .disabled(!settings.enableMemory) } header: { - SettingsSectionHeader(title: L("settings.memory"), symbol: "clock.arrow.trianglehead.counterclockwise.rotate.90") + SettingsSectionHeader(title: L("settings.memory")) } footer: { Text(L("settings.memory_help")) } @@ -166,7 +153,7 @@ struct GeneralSettingsView: View { .foregroundStyle(.orange) } } header: { - SettingsSectionHeader(title: L("settings.startup"), symbol: "power") + SettingsSectionHeader(title: L("settings.startup")) } Section { @@ -186,7 +173,7 @@ struct GeneralSettingsView: View { } menuBarIconPicker } header: { - SettingsSectionHeader(title: L("settings.interface"), symbol: "paintbrush") + SettingsSectionHeader(title: L("settings.interface")) } } .formStyle(.grouped) @@ -260,10 +247,9 @@ struct GeneralSettingsView: View { private struct SettingsSectionHeader: View { let title: String - let symbol: String var body: some View { - Label(title, systemImage: symbol) + Text(title) .font(.caption.weight(.semibold)) .foregroundStyle(.secondary) } diff --git a/Sources/UI/HistoryInsightsOverview.swift b/Sources/UI/HistoryInsightsOverview.swift index b3de31ea..3ac542b6 100644 --- a/Sources/UI/HistoryInsightsOverview.swift +++ b/Sources/UI/HistoryInsightsOverview.swift @@ -4,6 +4,7 @@ import SwiftUI struct HistoryInsightsOverview: View { let analytics: InputHistoryAnalytics @Binding var range: InputAnalyticsRange + @Binding var section: HistorySection private let metricColumns = [GridItem(.adaptive(minimum: 155), spacing: 12)] @@ -15,12 +16,12 @@ struct HistoryInsightsOverview: View { chartRow HourlyInsightCard(activity: analytics.hourlyActivity) } - .padding(20) + .padding(SettingsPageLayout.contentInset) } } private var rangeBar: some View { - HStack { + HStack(spacing: 12) { VStack(alignment: .leading, spacing: 2) { Text(L("history.overview.title")) .font(.headline) @@ -28,7 +29,12 @@ struct HistoryInsightsOverview: View { .font(.caption) .foregroundStyle(.secondary) } - Spacer() + .layoutPriority(1) + + Spacer(minLength: 8) + + HistoryModePicker(selection: $section) + Picker(L("history.range.label"), selection: $range) { ForEach(InputAnalyticsRange.allCases) { item in Text(rangeLabel(item)).tag(item) @@ -36,6 +42,7 @@ struct HistoryInsightsOverview: View { } .pickerStyle(.menu) .controlSize(.small) + .fixedSize() } } @@ -194,8 +201,10 @@ struct HistoryInsightsOverview: View { } private var chartEmptyState: some View { - HStack(spacing: 12) { - SettingsPageIllustration(kind: .activity, size: 64) + HStack(spacing: 8) { + Image(systemName: "chart.xyaxis.line") + .font(.system(size: 20)) + .foregroundStyle(.quaternary) Text(L("history.empty")) .font(.caption) .foregroundStyle(.secondary) diff --git a/Sources/UI/HistoryRecordsView.swift b/Sources/UI/HistoryRecordsView.swift index c038b008..c4222d67 100644 --- a/Sources/UI/HistoryRecordsView.swift +++ b/Sources/UI/HistoryRecordsView.swift @@ -3,6 +3,7 @@ import SwiftUI struct HistoryRecordsView: View { @ObservedObject var history: InputHistory + @Binding var section: HistorySection @ObservedObject private var settings = AppSettings.shared @State private var searchText = "" @State private var showClearConfirm = false @@ -19,19 +20,21 @@ struct HistoryRecordsView: View { HStack(spacing: 10) { TextField(L("history.search"), text: $searchText) .textFieldStyle(.roundedBorder) - .frame(maxWidth: 320) + .frame(minWidth: 180, maxWidth: 260) Text(String(format: L("history.records_count"), filteredRecords.count)) .font(.caption) .foregroundStyle(.secondary) - Spacer() + Spacer(minLength: 0) + + HistoryModePicker(selection: $section) Picker(L("settings.history_retention"), selection: $settings.historyRetention) { ForEach(HistoryRetention.allCases, id: \.self) { Text($0.label) } } .labelsHidden() - .frame(width: 120) + .frame(width: 110) .controlSize(.small) Button(role: .destructive) { @@ -51,7 +54,7 @@ struct HistoryRecordsView: View { Text(L("common.cannot_undo")) } } - .padding(.horizontal, 20) + .padding(.horizontal, SettingsPageLayout.contentInset) .padding(.vertical, 10) } @@ -66,7 +69,7 @@ struct HistoryRecordsView: View { recordCard(record) } } - .padding(16) + .padding(SettingsPageLayout.contentInset) } } } @@ -74,13 +77,9 @@ struct HistoryRecordsView: View { private var emptyState: some View { VStack(spacing: 10) { Spacer() - if searchText.isEmpty { - SettingsPageIllustration(kind: .activity, size: 84) - } else { - Image(systemName: "magnifyingglass") - .font(.system(size: 28)) - .foregroundStyle(.quaternary) - } + Image(systemName: searchText.isEmpty ? "clock.arrow.circlepath" : "magnifyingglass") + .font(.system(size: 24)) + .foregroundStyle(.quaternary) Text(searchText.isEmpty ? L("history.empty") : L("history.no_match")) .font(.callout) .foregroundStyle(.secondary) diff --git a/Sources/UI/HistoryStatsView.swift b/Sources/UI/HistoryStatsView.swift index 5a64d985..48be25ed 100644 --- a/Sources/UI/HistoryStatsView.swift +++ b/Sources/UI/HistoryStatsView.swift @@ -1,6 +1,6 @@ import SwiftUI -private enum HistorySection: String, CaseIterable, Identifiable { +enum HistorySection: String, CaseIterable, Identifiable { case insights case records @@ -14,33 +14,34 @@ struct HistoryStatsView: View { @State private var range: InputAnalyticsRange = .sevenDays var body: some View { - VStack(spacing: 0) { - SettingsPageHeader( - kind: .activity, - title: L("history.title"), - subtitle: L("history.subtitle") - ) { - Picker(L("history.section.label"), selection: $section) { - ForEach(HistorySection.allCases) { item in - Text(item.label).tag(item) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(width: 210) - } - Divider() - + Group { switch section { case .insights: HistoryInsightsOverview( analytics: .make(records: history.records, range: range), - range: $range + range: $range, + section: $section ) case .records: - HistoryRecordsView(history: history) + HistoryRecordsView(history: history, section: $section) } } .settingsPageSurface() } } + +struct HistoryModePicker: View { + @Binding var selection: HistorySection + + var body: some View { + Picker(L("history.section.label"), selection: $selection) { + ForEach(HistorySection.allCases) { item in + Text(item.label).tag(item) + } + } + .pickerStyle(.segmented) + .controlSize(.small) + .labelsHidden() + .frame(width: 140) + } +} diff --git a/Sources/UI/IndustryLexiconView.swift b/Sources/UI/IndustryLexiconView.swift index 36af7d05..a0b45b32 100644 --- a/Sources/UI/IndustryLexiconView.swift +++ b/Sources/UI/IndustryLexiconView.swift @@ -8,26 +8,14 @@ struct IndustryLexiconView: View { private let catalog = IndustryLexiconCatalog.shared var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 12) { - Label(L("industry.lexicon.title"), systemImage: "books.vertical") - .font(.headline) - Spacer() - Picker(L("industry.lexicon.selection"), selection: $settings.industryLexicon) { - ForEach(IndustryLexiconID.allCases) { industry in - Label(industry.label, systemImage: industry.symbolName) - .tag(industry) - } + Section { + Picker(L("industry.lexicon.selection"), selection: $settings.industryLexicon) { + ForEach(IndustryLexiconID.allCases) { industry in + Text(industry.label) + .tag(industry) } - .labelsHidden() - .frame(width: 190) - .accessibilityLabel(L("industry.lexicon.selection")) } - Text(L("industry.lexicon.subtitle")) - .font(.caption) - .foregroundStyle(.secondary) - if let pack = activePack { Label( String(format: L("industry.lexicon.term_count_fmt"), pack.terms.count), @@ -58,8 +46,11 @@ struct IndustryLexiconView: View { Label(L("industry.lexicon.inactive"), systemImage: "minus.circle") .font(.caption) .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) } + } header: { + Text(L("industry.lexicon.title")) + } footer: { + Text(L("industry.lexicon.subtitle")) } .onChange(of: settings.industryLexicon) { _, _ in searchText = "" @@ -99,12 +90,6 @@ struct IndustryLexiconView: View { } } .frame(maxHeight: 170) - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay { - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - } } } } diff --git a/Sources/UI/IntegrationsSettingsView.swift b/Sources/UI/IntegrationsSettingsView.swift index 37919950..50bede29 100644 --- a/Sources/UI/IntegrationsSettingsView.swift +++ b/Sources/UI/IntegrationsSettingsView.swift @@ -8,75 +8,66 @@ struct IntegrationsSettingsView: View { private let registry = IntegrationClientRegistry() var body: some View { - VStack(spacing: 0) { - SettingsPageHeader( - kind: .integrations, - title: L("settings.page.integrations.title"), - subtitle: L("settings.page.integrations.subtitle") - ) - Divider() - - Form { - Section(L("settings.developer_interface")) { - Toggle(isOn: $settings.developerInterfaceEnabled) { - VStack(alignment: .leading, spacing: 2) { - Text(L("settings.developer_interface")) - Text(L("settings.developer_interface_help")) - .font(.caption) - .foregroundStyle(.secondary) - } + Form { + Section(L("settings.developer_interface")) { + Toggle(isOn: $settings.developerInterfaceEnabled) { + VStack(alignment: .leading, spacing: 2) { + Text(L("settings.developer_interface")) + Text(L("settings.developer_interface_help")) + .font(.caption) + .foregroundStyle(.secondary) } } + } - Section(L("settings.developer_registered_apps")) { - if approvedClients.isEmpty { - Text(L("settings.developer_no_registered_apps")) - .foregroundStyle(.secondary) - } else { - ForEach(approvedClients) { client in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(client.displayName) - Text(clientDetail(client)) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - } - Spacer() - Button(L("common.delete")) { - registry.revoke(clientID: client.id) - refreshClients() - } + Section(L("settings.developer_registered_apps")) { + if approvedClients.isEmpty { + Text(L("settings.developer_no_registered_apps")) + .foregroundStyle(.secondary) + } else { + ForEach(approvedClients) { client in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(client.displayName) + Text(clientDetail(client)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + Button(L("common.delete")) { + registry.revoke(clientID: client.id) + refreshClients() } } } - - Button(L("settings.developer_add_app")) { addApp() } - Button(L("settings.developer_register_cli")) { registerCLIHelper() } } - Section("HTTP") { - LabeledContent(L("settings.developer_http_address")) { - Text("127.0.0.1:\(settings.developerHTTPPort)") - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - } - LabeledContent(L("settings.developer_http_token")) { - Text(settings.developerHTTPToken) - .font(.system(.caption, design: .monospaced)) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - } - Button(L("settings.developer_reset_token")) { - settings.resetDeveloperHTTPToken() - } + Button(L("settings.developer_add_app")) { addApp() } + Button(L("settings.developer_register_cli")) { registerCLIHelper() } + } + + Section("HTTP") { + LabeledContent(L("settings.developer_http_address")) { + Text("127.0.0.1:\(settings.developerHTTPPort)") + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + } + LabeledContent(L("settings.developer_http_token")) { + Text(settings.developerHTTPToken) + .font(.system(.caption, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + Button(L("settings.developer_reset_token")) { + settings.resetDeveloperHTTPToken() } } - .formStyle(.grouped) - .scrollContentBackground(.hidden) } + .formStyle(.grouped) + .scrollContentBackground(.hidden) .settingsPageSurface() .onAppear(perform: refreshClients) } diff --git a/Sources/UI/ModelManagementActions.swift b/Sources/UI/ModelManagementActions.swift index b11b8eee..0ecc568a 100644 --- a/Sources/UI/ModelManagementActions.swift +++ b/Sources/UI/ModelManagementActions.swift @@ -51,12 +51,6 @@ extension ModelManagementView { var activeDownloadsSection: some View { VStack(alignment: .leading, spacing: 10) { - Label( - String(format: L("model.downloads_active"), activeDownloads.count), - systemImage: "arrow.down.circle.fill" - ) - .font(.headline) - ForEach(activeDownloads) { download in VStack(alignment: .leading, spacing: 6) { HStack { @@ -85,8 +79,7 @@ extension ModelManagementView { .font(.system(size: 10)) .foregroundStyle(.tertiary) } - .padding(10) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + .padding(.vertical, 4) } } } diff --git a/Sources/UI/ModelManagementEnginePicker.swift b/Sources/UI/ModelManagementEnginePicker.swift index 3de2f105..e48e324f 100644 --- a/Sources/UI/ModelManagementEnginePicker.swift +++ b/Sources/UI/ModelManagementEnginePicker.swift @@ -1,66 +1,15 @@ import SwiftUI -import AppKit extension ModelManagementView { var enginePickerSection: some View { - VStack(alignment: .leading, spacing: 12) { - Label(L("model.speech_recognition"), systemImage: "waveform") - .font(.headline) - - VStack(alignment: .leading, spacing: 8) { - Text(L("settings.speech_engine")) - .font(.system(size: 11)) - .foregroundStyle(.secondary) - - speechEnginePicker - } - } - } - - private var speechEnginePicker: some View { - HStack(spacing: 0) { + Picker(L("settings.speech_engine"), selection: $settings.speechEngine) { ForEach(SpeechEngineType.selectableCases, id: \.self) { engine in - speechEngineButton(engine) - } - } - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) - } - - private func speechEngineButton(_ engine: SpeechEngineType) -> some View { - let isSelected = settings.speechEngine == engine - - return Button { - settings.speechEngine = engine - } label: { - VStack(spacing: 2) { - Image(systemName: engine.pickerIcon) - .font(.system(size: 14)) - Text(engine.pickerTitle) - .font(.system(size: 10, weight: isSelected ? .semibold : .medium)) - .lineLimit(1) - .minimumScaleFactor(0.75) + .tag(engine) } - .frame(maxWidth: .infinity, minHeight: 44) - .contentShape(Rectangle()) } - .buttonStyle(.plain) - .background( - isSelected - ? Color.accentColor.opacity(0.15) - : Color.clear - ) - .foregroundStyle( - isSelected - ? Color.accentColor - : Color.primary - ) - .help(engine.label) + .pickerStyle(.segmented) + .labelsHidden() } } @@ -70,18 +19,10 @@ private extension SpeechEngineType { case .whisper: return L("engine.whisper_short") case .apple: return L("engine.apple_short") case .volc: return L("engine.volc_short") - case .qwen3: return L("engine.qwen3_short") + case .qwen3: + return "\(L("engine.qwen3_short")) · \(L("common.recommended_short"))" case .mimo: return L("engine.mimo_short") } } - var pickerIcon: String { - switch self { - case .whisper: return "waveform" - case .apple: return "apple.logo" - case .volc: return "cloud.bolt.fill" - case .qwen3: return "q.circle.fill" - case .mimo: return "m.circle.fill" - } - } } diff --git a/Sources/UI/ModelManagementFamilies.swift b/Sources/UI/ModelManagementFamilies.swift index fd5b67a2..50226cde 100644 --- a/Sources/UI/ModelManagementFamilies.swift +++ b/Sources/UI/ModelManagementFamilies.swift @@ -1,68 +1,74 @@ import SwiftUI -extension ModelManagementView { - var familyPicker: some View { - HStack(spacing: 0) { - ForEach(ModelCatalog.ModelFamily.allCases, id: \.self) { family in - familyButton(family) - } - remoteFamilyButton +enum FormattingModelType: String, CaseIterable { + case qwen + case gemma + case llama + case remote + case custom + + var family: ModelCatalog.ModelFamily? { + switch self { + case .qwen: .qwen + case .gemma: .gemma + case .llama: .llama + case .remote, .custom: nil } - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) } - func familyButton(_ family: ModelCatalog.ModelFamily) -> some View { - let isSelected = !settings.useRemoteLLM && selectedModelFamily == family + var isRecommended: Bool { self == .qwen } - return Button(action: { selectLocalFamily(family) }) { - VStack(spacing: 2) { - Image(systemName: family.icon) - .font(.system(size: 14)) - Text(family.rawValue) - .font(.system(size: 10, weight: isSelected ? .semibold : .medium)) - } - .frame(maxWidth: .infinity, minHeight: 44) - .contentShape(Rectangle()) + var title: String { + switch self { + case .qwen: + "\(ModelCatalog.ModelFamily.qwen.rawValue) · \(L("common.recommended_short"))" + case .gemma: ModelCatalog.ModelFamily.gemma.rawValue + case .llama: ModelCatalog.ModelFamily.llama.rawValue + case .remote: L("model.family.remote") + case .custom: L("common.custom") } - .buttonStyle(.plain) - .background( - isSelected - ? Color.accentColor.opacity(0.15) - : Color.clear - ) - .foregroundStyle( - isSelected - ? Color.accentColor - : Color.primary - ) } +} - var remoteFamilyButton: some View { - Button(action: selectRemoteLLM) { - VStack(spacing: 2) { - Image(systemName: "cloud.fill") - .font(.system(size: 14)) - Text(L("model.family.remote")) - .font(.system(size: 10, weight: settings.useRemoteLLM ? .semibold : .medium)) +extension ModelManagementView { + var familyPicker: some View { + Picker(L("model.family.title"), selection: familySelection) { + ForEach(FormattingModelType.allCases, id: \.self) { type in + Text(type.title) + .tag(type) } - .frame(maxWidth: .infinity, minHeight: 44) - .contentShape(Rectangle()) } - .buttonStyle(.plain) - .background( - settings.useRemoteLLM - ? Color.accentColor.opacity(0.15) - : Color.clear - ) - .foregroundStyle( - settings.useRemoteLLM - ? Color.accentColor - : Color.primary + .pickerStyle(.segmented) + .labelsHidden() + } + + private var familySelection: Binding { + Binding( + get: { + if settings.useRemoteLLM { + return .remote + } + switch selectedModelFamily { + case .qwen: return .qwen + case .gemma: return .gemma + case .llama: return .llama + case nil: return .custom + } + }, + set: { selection in + switch selection { + case .qwen: + selectLocalFamily(.qwen) + case .gemma: + selectLocalFamily(.gemma) + case .llama: + selectLocalFamily(.llama) + case .remote: + selectRemoteLLM() + case .custom: + selectCustomLLM() + } + } ) } @@ -82,4 +88,15 @@ extension ModelManagementView { settings.useRemoteLLM = true } } + + func selectCustomLLM() { + selectedModelFamily = nil + if settings.useRemoteLLM { + settings.useRemoteLLM = false + if let activeModel = catalog.llmModels.first(where: { $0.id == settings.llmModel }), + activeModel.family == nil { + onLoadLLM?() + } + } + } } diff --git a/Sources/UI/ModelManagementRows.swift b/Sources/UI/ModelManagementRows.swift index 6cb9394b..accb7e64 100644 --- a/Sources/UI/ModelManagementRows.swift +++ b/Sources/UI/ModelManagementRows.swift @@ -16,12 +16,6 @@ extension ModelManagementView { if index < models.count - 1 { Divider().padding(.horizontal, 10) } } } - .background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - ) } func modelRow( @@ -228,9 +222,7 @@ extension ModelManagementView { onUnloadLLM?() settings.useRemoteLLM = false settings.llmModel = model.id - if let family = model.family { - selectedModelFamily = family - } + selectedModelFamily = model.family onLoadLLM?() case .asr: onUnloadLocalASR?() diff --git a/Sources/UI/ModelManagementSections.swift b/Sources/UI/ModelManagementSections.swift index 4a34ac2e..1a5134c5 100644 --- a/Sources/UI/ModelManagementSections.swift +++ b/Sources/UI/ModelManagementSections.swift @@ -5,9 +5,6 @@ extension ModelManagementView { var deviceInfoSection: some View { let info = DeviceCapability.current return VStack(alignment: .leading, spacing: 10) { - Label(L("device.title"), systemImage: "cpu") - .font(.headline) - HStack(spacing: 16) { deviceInfoItem( icon: "memorychip", @@ -53,9 +50,6 @@ extension ModelManagementView { var storageSection: some View { VStack(alignment: .leading, spacing: 8) { - Label(L("model.storage.title"), systemImage: "externaldrive") - .font(.headline) - Text(ModelStorage.root.path) .font(.system(size: 10, design: .monospaced)) .foregroundStyle(.secondary) @@ -85,17 +79,13 @@ extension ModelManagementView { } } + @ViewBuilder var preloadSection: some View { - VStack(alignment: .leading, spacing: 10) { - Label(L("model.preload.title"), systemImage: "bolt.circle") - .font(.headline) - - Toggle(L("model.preload.speech"), isOn: $settings.preloadSpeechModelOnLaunch) - .help(L("model.preload.speech_help")) + Toggle(L("model.preload.speech"), isOn: $settings.preloadSpeechModelOnLaunch) + .help(L("model.preload.speech_help")) - Toggle(L("model.preload.formatting"), isOn: $settings.preloadFormattingModelOnLaunch) - .help(L("model.preload.formatting_help")) - } + Toggle(L("model.preload.formatting"), isOn: $settings.preloadFormattingModelOnLaunch) + .help(L("model.preload.formatting_help")) } var whisperSection: some View { @@ -145,9 +135,6 @@ extension ModelManagementView { var llmSection: some View { VStack(alignment: .leading, spacing: 12) { - Label(L("model.text_formatting"), systemImage: "brain") - .font(.headline) - if appState.lastFormattingDurationSeconds > 0 { HStack(spacing: 8) { Text(L("model.last_formatting")) @@ -184,37 +171,36 @@ extension ModelManagementView { if let family = selectedModelFamily { let familyModels = catalog.llmModels.filter { $0.family == family } groupedLLMModelList(familyModels, activeID: settings.llmModel) - } - let customModels = catalog.llmModels.filter { $0.family == nil } - if !customModels.isEmpty { - Text(L("model.custom_local")) - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.secondary) - .padding(.horizontal, 2) - modelList(customModels, activeID: settings.llmModel, type: .llm) - } + } else { + let customModels = catalog.llmModels.filter { $0.family == nil } + if !customModels.isEmpty { + modelList(customModels, activeID: settings.llmModel, type: .llm) + } - HStack(spacing: 8) { - TextField(L("model.custom_id_placeholder"), text: $customLLMInput) - .textFieldStyle(.roundedBorder) - .font(.system(size: 11)) - Button(L("common.add")) { - catalog.addCustomLLM(customLLMInput) - customLLMInput = "" + HStack(spacing: 8) { + TextField(L("model.custom_id_placeholder"), text: $customLLMInput) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11)) + Button(L("common.add")) { + catalog.addCustomLLM(customLLMInput) + customLLMInput = "" + } + .controlSize(.small) + .disabled(customLLMInput.isEmpty) + } + Button(L("model.import_local")) { + importLocalLLM() } .controlSize(.small) - .disabled(customLLMInput.isEmpty) - } - Button(L("model.import_local")) { - importLocalLLM() } - .controlSize(.small) } func syncSelectedFamilyFromActiveModel() { guard !settings.useRemoteLLM else { return } - if let family = catalog.llmModels.first(where: { $0.id == settings.llmModel })?.family { - selectedModelFamily = family + if let activeModel = catalog.llmModels.first(where: { $0.id == settings.llmModel }) { + selectedModelFamily = activeModel.family + } else { + selectedModelFamily = .qwen } } diff --git a/Sources/UI/ModelManagementView.swift b/Sources/UI/ModelManagementView.swift index 322859e1..091912ec 100644 --- a/Sources/UI/ModelManagementView.swift +++ b/Sources/UI/ModelManagementView.swift @@ -20,37 +20,36 @@ struct ModelManagementView: View { let benchmarkEngine = LLMEngine() var body: some View { - VStack(spacing: 0) { - SettingsPageHeader( - kind: .models, - title: L("settings.page.models.title"), - subtitle: L("settings.page.models.subtitle") - ) { - SettingsPageBadge(title: settings.speechEngine.label, symbol: "waveform") + Form { + if hasActiveDownloads { + Section(String(format: L("model.downloads_active"), activeDownloads.count)) { + activeDownloadsSection + } } - Divider() - ScrollView { - VStack(alignment: .leading, spacing: 14) { - if hasActiveDownloads { - SettingsPanel { activeDownloadsSection } - } + Section(L("device.title")) { + deviceInfoSection + } - SettingsPanel { deviceInfoSection } + Section(L("model.speech_recognition")) { + enginePickerSection + selectedRecognitionConfiguration + } - SettingsPanel { - enginePickerSection - Divider().padding(.vertical, 4) - selectedRecognitionConfiguration - } + Section(L("model.text_formatting")) { + llmSection + } - SettingsPanel { llmSection } - SettingsPanel { preloadSection } - SettingsPanel { storageSection } - } - .padding(20) + Section(L("model.preload.title")) { + preloadSection + } + + Section(L("model.storage.title")) { + storageSection } } + .formStyle(.grouped) + .scrollContentBackground(.hidden) .settingsPageSurface() .onAppear { catalog.refreshStatus(recheckingErrors: true) diff --git a/Sources/UI/SettingsView.swift b/Sources/UI/SettingsView.swift index c5b5eea9..9dd9d969 100644 --- a/Sources/UI/SettingsView.swift +++ b/Sources/UI/SettingsView.swift @@ -1,10 +1,14 @@ +import AppKit import SwiftUI enum SettingsWindowLayout { - static let width: CGFloat = 920 + static let width: CGFloat = 760 static let height: CGFloat = 680 - static let minimumWidth: CGFloat = 760 - static let minimumHeight: CGFloat = 540 + static let styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable] + + static var contentSize: NSSize { + NSSize(width: width, height: height) + } } enum SettingsWindowTitle { @@ -45,10 +49,7 @@ struct SettingsView: View { AboutView() .tabItem { Label(L("tab.about"), systemImage: "info.circle") } } - .frame( - minWidth: SettingsWindowLayout.minimumWidth, - minHeight: SettingsWindowLayout.minimumHeight - ) + .frame(width: SettingsWindowLayout.width, height: SettingsWindowLayout.height) .background(Color(nsColor: .windowBackgroundColor)) .id(settings.uiLanguage) } diff --git a/Sources/UI/SettingsVoiceIllustration.swift b/Sources/UI/SettingsVoiceIllustration.swift index e8dcf954..051c45a3 100644 --- a/Sources/UI/SettingsVoiceIllustration.swift +++ b/Sources/UI/SettingsVoiceIllustration.swift @@ -1,163 +1,8 @@ import AppKit import SwiftUI -enum SettingsPageKind { - case activity - case general - case models - case style - case integrations - case about - - var assetName: String { - switch self { - case .activity: "SettingsActivityIllustration" - case .general: "SettingsVoiceIllustration" - case .models: "SettingsModelsIllustration" - case .style: "SettingsStyleIllustration" - case .integrations: "SettingsIntegrationsIllustration" - case .about: "SettingsAboutIllustration" - } - } - - var fallbackSymbol: String { - switch self { - case .activity: "chart.line.uptrend.xyaxis" - case .general: "waveform.and.mic" - case .models: "cpu" - case .style: "text.book.closed" - case .integrations: "point.3.connected.trianglepath.dotted" - case .about: "info.circle" - } - } -} - -struct SettingsPageHeader: View { - let kind: SettingsPageKind - let title: String - let subtitle: String - let accessory: Accessory - - @Environment(\.colorSchemeContrast) private var contrast - - init( - kind: SettingsPageKind, - title: String, - subtitle: String, - @ViewBuilder accessory: () -> Accessory - ) { - self.kind = kind - self.title = title - self.subtitle = subtitle - self.accessory = accessory() - } - - var body: some View { - HStack(spacing: 16) { - VStack(alignment: .leading, spacing: 5) { - Text(title) - .font(.title2.weight(.semibold)) - Text(subtitle) - .font(.callout) - .foregroundStyle(.secondary) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer(minLength: 16) - accessory - SettingsPageIllustration(kind: kind, size: 72) - } - .padding(.horizontal, 20) - .padding(.vertical, 12) - .frame(minHeight: 96) - .background(Color(nsColor: .windowBackgroundColor)) - } -} - -extension SettingsPageHeader where Accessory == EmptyView { - init(kind: SettingsPageKind, title: String, subtitle: String) { - self.init(kind: kind, title: title, subtitle: subtitle) { EmptyView() } - } -} - -struct SettingsPageIllustration: View { - let kind: SettingsPageKind - let size: CGFloat - - @Environment(\.colorSchemeContrast) private var contrast - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color.accentColor.opacity(contrast == .increased ? 0.16 : 0.09)) - .overlay { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.accentColor.opacity(contrast == .increased ? 0.5 : 0.2), lineWidth: 1) - } - - Group { - if let image = image { - Image(nsImage: image) - .resizable() - .interpolation(.high) - .aspectRatio(contentMode: .fit) - } else { - Image(systemName: kind.fallbackSymbol) - .resizable() - .scaledToFit() - .foregroundStyle(.tint) - .padding(16) - } - } - .padding(6) - } - .frame(width: size, height: size) - .accessibilityHidden(true) - } - - private var image: NSImage? { - guard let url = AppResources.bundle.url( - forResource: kind.assetName, - withExtension: "png" - ) else { return nil } - return NSImage(contentsOf: url) - } -} - -struct SettingsPageBadge: View { - let title: String - let symbol: String - - var body: some View { - Label(title, systemImage: symbol) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(Color(nsColor: .controlBackgroundColor), in: Capsule()) - .overlay { - Capsule() - .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5) - } - } -} - -struct SettingsPanel: View { - let content: Content - - init(@ViewBuilder content: () -> Content) { - self.content = content() - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - content - } - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(SettingsCardBackground()) - } +enum SettingsPageLayout { + static let contentInset: CGFloat = 28 } struct SettingsCardBackground: View { diff --git a/Tests/OpenTypeTests/ConfigurationTests.swift b/Tests/OpenTypeTests/ConfigurationTests.swift index ec5d0e6f..cbdd20bf 100644 --- a/Tests/OpenTypeTests/ConfigurationTests.swift +++ b/Tests/OpenTypeTests/ConfigurationTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import XCTest @testable import OpenType @@ -58,10 +59,19 @@ final class ConfigurationTests: XCTestCase { "whisper", "apple", "volc", "qwen3", "mimo", ]) XCTAssertEqual(SpeechEngineType.selectableCases.map(\.rawValue), [ - "whisper", "apple", "volc", "qwen3", + "qwen3", "whisper", "apple", "volc", ]) } + @MainActor + func testFormattingModelTypesPutRecommendedQwenFirstAndCustomLast() { + XCTAssertEqual(FormattingModelType.allCases.map(\.rawValue), [ + "qwen", "gemma", "llama", "remote", "custom", + ]) + XCTAssertTrue(FormattingModelType.qwen.isRecommended) + XCTAssertTrue(FormattingModelType.allCases.dropFirst().allSatisfy { !$0.isRecommended }) + } + func testQwenASRDefaultUsesNativeCompatibleModel() { XCTAssertEqual(QwenASRModel.defaultID, "mlx-community/Qwen3-ASR-1.7B-bf16") } @@ -209,8 +219,14 @@ final class ConfigurationTests: XCTestCase { XCTAssertEqual(SettingsWindowTitle.text(for: .chinese), "Utter 设置") } - func testSettingsWindowWidthAllowsEnglishTabLabels() { - XCTAssertGreaterThanOrEqual(SettingsWindowLayout.width, 760) + func testSettingsWindowUsesFixedContentSize() { + XCTAssertEqual(SettingsWindowLayout.width, 760) + XCTAssertEqual(SettingsWindowLayout.height, 680) + XCTAssertEqual(SettingsWindowLayout.width, SettingsWindowLayout.contentSize.width) + XCTAssertEqual(SettingsWindowLayout.height, SettingsWindowLayout.contentSize.height) + XCTAssertFalse(SettingsWindowLayout.styleMask.contains(.resizable)) + XCTAssertTrue(SettingsWindowLayout.styleMask.contains(.closable)) + XCTAssertTrue(SettingsWindowLayout.styleMask.contains(.miniaturizable)) } func testInstantInsertDefaultsOff() { diff --git a/docs/sdlc/changes/2026-08-30-history-mode-switch/intent.md b/docs/sdlc/changes/2026-08-30-history-mode-switch/intent.md new file mode 100644 index 00000000..b894284c --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-history-mode-switch/intent.md @@ -0,0 +1,41 @@ +# Intent: Integrate the history mode switch into page controls + +## Problem + +The Insights/History segmented control currently occupies a centered, +full-width strip between the primary settings tabs and the history content. It +looks like a third navigation layer and creates a large empty band that is not +connected to either mode's controls. + +## Outcome + +The mode switch remains a native segmented control but becomes a compact, +contextual control inside each history mode's top action row. + +## Scope + +In scope: placement, size, spacing, and shared rendering of the history mode +switch in Insights and History. + +Out of scope: primary settings tabs, analytics cards, history data, search and +retention behavior, localization copy, and fixed window geometry. + +## Constraints + +- Preserve the fixed 760 by 680 settings window and 28-point history inset. +- Keep one selection state shared by both modes. +- Use native compact controls and semantic system styling. +- Preserve history search, retention, analytics range, and data behavior. + +## Acceptance criteria + +- The switch no longer owns a separate full-width strip or divider. +- It remains visible and keyboard accessible in both modes. +- It aligns to the 28-point content grid and nearby controls without clipping + in Chinese or English. +- Both modes are inspected in the real fixed-size window in light and dark + appearances. + +## Open questions + +None. Human review retains final visual approval. diff --git a/docs/sdlc/changes/2026-08-30-history-mode-switch/plan.md b/docs/sdlc/changes/2026-08-30-history-mode-switch/plan.md new file mode 100644 index 00000000..d507056c --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-history-mode-switch/plan.md @@ -0,0 +1,24 @@ +# Plan: Integrate the history mode switch into page controls + +## Work items + +- [x] Record the awkward third-layer navigation problem and layout target. +- [x] Extract one compact shared history mode picker. +- [x] Place it in the Insights header and History toolbar. +- [x] Verify both modes across languages and appearances in the real window. +- [x] Record automated and visual evidence. + +## Verification plan + +- [x] `python3 scripts/sdlc.py validate --worktree` +- [x] `bash scripts/ci-basic-checks.sh` +- [x] `swift test` +- [x] `bash scripts/build-and-run.sh --verify` +- [x] Real-window Chinese/English light/dark inspection of both modes +- [x] `git diff --check` + +## Human gates + +The requested redesign is the product direction. A reviewer must still approve +the real-window result before merge; this implementation records no human +approval. diff --git a/docs/sdlc/changes/2026-08-30-history-mode-switch/spec.md b/docs/sdlc/changes/2026-08-30-history-mode-switch/spec.md new file mode 100644 index 00000000..ffa10e9a --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-history-mode-switch/spec.md @@ -0,0 +1,52 @@ +# Spec: Integrate the history mode switch into page controls + +## Context + +The existing picker is correctly modeled as a native content-view switch, but +its parent-level centered strip creates a visually detached third navigation +layer between the settings tabs and the history content. + +## Design + +### Layout specification + +The fixed settings shell and 28-point history content inset remain unchanged. +The separate centered mode strip is removed. + +- Mode control: native segmented picker, small control size, 140-point width. +- Insights header: flexible title/subtitle, then the mode control, then the + time-range picker, with 12-point spacing. +- History toolbar: flexible search field, record count, then the same mode + control, retention picker, and clear action, with 10-point spacing. +- No additional background, border, divider, or absolute positioning. + +The mode control remains visually stable near the trailing side of both modes' +top control rows while the adjacent contextual controls may differ. + +## Behavior + +Both placements bind to the single `HistorySection` state owned by +`HistoryStatsView`. Switching modes replaces the content while preserving the +analytics range, search, retention, and history data behavior already owned by +their respective views. + +## Safety and failure modes + +- English labels can widen the header. The mode control has a fixed compact + width and the title region remains flexible. +- The History toolbar contains more controls. Its search field uses a bounded + width so trailing controls remain reachable at 760 points. +- Moving the picker must not create separate selection state in either child. + +## Test strategy + +- Compile and run the full Swift test suite. +- Run repository SDLC and invariant checks. +- Build the app and inspect Insights and History in Chinese and English, light + and dark, at the fixed 760 by 680 content size. + +## Rollout and rollback + +Merge through normal review after real-window approval. Revert this bundle and +restore the parent-level mode strip if either toolbar clips. No persisted data +or setting migration is involved. diff --git a/docs/sdlc/changes/2026-08-30-history-mode-switch/state.json b/docs/sdlc/changes/2026-08-30-history-mode-switch/state.json new file mode 100644 index 00000000..a3a72979 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-history-mode-switch/state.json @@ -0,0 +1,27 @@ +{ + "schemaVersion": 1, + "id": "2026-08-30-history-mode-switch", + "title": "Integrate the history mode switch into page controls", + "risk": "medium", + "status": "verified", + "owners": [ + "repository maintainer" + ], + "acceptanceCriteria": [ + "The Insights and History switch no longer occupies a separate full-width strip below the primary settings tabs.", + "The compact native switch remains visible and keyboard accessible in both history modes.", + "The switch aligns with the existing 28-point content grid and adjacent contextual controls without clipping in Chinese or English.", + "The real fixed-size window is inspected in light and dark appearances for both history modes." + ], + "governedPaths": [ + "Sources/UI/HistoryStatsView.swift", + "Sources/UI/HistoryInsightsOverview.swift", + "Sources/UI/HistoryRecordsView.swift" + ], + "artifacts": { + "intent": "intent.md", + "spec": "spec.md", + "plan": "plan.md", + "verification": "verification.md" + } +} diff --git a/docs/sdlc/changes/2026-08-30-history-mode-switch/verification.md b/docs/sdlc/changes/2026-08-30-history-mode-switch/verification.md new file mode 100644 index 00000000..bc0192d9 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-history-mode-switch/verification.md @@ -0,0 +1,31 @@ +# Verification: Integrate the history mode switch into page controls + +## Evidence + +| Check | Result | Evidence | +|---|---|---| +| `python3 scripts/sdlc.py validate --worktree` | Pass | All five change bundles validated after this bundle advanced to `verified` | +| `bash scripts/ci-basic-checks.sh` | Pass | SDLC harness, plist, localization, vocabulary, resources, and repository invariants passed | +| `swift test` | Pass | 564 XCTest cases passed, 8 environment-gated cases skipped, and the Swift Testing model-upgrade case passed | +| `bash scripts/build-and-run.sh --verify` | Pass | The final code compiled and assembled the local app bundle | +| Real-window inspection | Pass | Insights and History were inspected at 760 by 680 in Chinese and English, in light and dark appearances; control placement, toolbar fit, contrast, and keyboard-accessible AX roles passed | +| `git diff --check` | Pass | No whitespace errors | + +## Acceptance criteria + +- Separate mode strip removal — pass; the parent-level band and divider are gone. +- Compact shared control in both modes — pass; both use the same 140-point, + small native segmented picker bound to one selection state. +- Chinese/English alignment — pass; Insights places the switch before the time + range and History places it before retention controls without clipping. +- Light/dark real-window inspection — pass for both modes and languages. + +## Residual risk + +The History toolbar remains intentionally dense because it combines search, +record count, mode, retention, and clear controls. At the fixed window width it +has verified spacing, but final visual acceptance remains a human decision. + +## Decision + +Ready for review. Human approval is recorded separately. diff --git a/docs/sdlc/changes/2026-08-30-model-type-selection/intent.md b/docs/sdlc/changes/2026-08-30-model-type-selection/intent.md new file mode 100644 index 00000000..a3f43a79 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-model-type-selection/intent.md @@ -0,0 +1,41 @@ +# Intent: Make custom models an explicit recommended-first type + +## Problem + +Custom model entry and local import are rendered beneath every local LLM family +instead of belonging to an explicit type. An active custom model has no family, +so the picker falls back to Qwen. Speech-engine presentation also inherits an +enum order that places the recommended Qwen engine last. + +## Outcome + +Custom becomes a first-class text-formatting type. Qwen is first and visibly +recommended in both speech and formatting type selectors. Selection state, +content, and the active model remain coherent. + +## Scope + +In scope: display order and labels of speech/formatting types, custom-type +selection, conditional custom-model controls, and active-model synchronization. + +Out of scope: model catalog contents, download/import implementation, default +persisted models, inference behavior, and remote-provider configuration. + +## Constraints + +- Preserve existing persisted `SpeechEngineType` raw values and LLM model IDs. +- Keep standard native segmented controls and the existing grouped form grid. +- Do not change the active model merely by browsing another type. +- Preserve all unload/load callbacks when leaving or entering Remote. + +## Acceptance criteria + +- Formatting order is Qwen recommended, Gemma, Llama, Remote, Custom. +- Custom controls and family-less models appear only under Custom. +- Active family-less models synchronize to Custom. +- Speech order is Qwen recommended, Whisper, Apple, Doubao. +- Both selectors fit the fixed window in Chinese/English and light/dark. + +## Open questions + +None. Human review retains final visual approval. diff --git a/docs/sdlc/changes/2026-08-30-model-type-selection/plan.md b/docs/sdlc/changes/2026-08-30-model-type-selection/plan.md new file mode 100644 index 00000000..d47e9642 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-model-type-selection/plan.md @@ -0,0 +1,29 @@ +# Plan: Make custom models an explicit recommended-first type + +## Work items + +- [x] Record the incorrect custom-type ownership and explicit ordering target. +- [x] Add a red-capable focused test for type order and recommendation state. +- [x] Introduce the explicit Custom formatting type and conditional content. +- [x] Put recommended Qwen first in the speech selector. +- [x] Verify selection callbacks and active custom-model synchronization. +- [x] Run automated and real-window verification. +- [x] Record evidence and residual risk. + +## Verification plan + +- [x] Focused model-type regression tests +- [x] `python3 scripts/sdlc.py validate --worktree` +- [x] `bash scripts/ci-basic-checks.sh` +- [x] `swift test` +- [x] `bash scripts/build-and-run.sh --verify` +- [x] Real-window Chinese/English light/dark inspection +- [x] `git diff --check` + +## Human gates + +The requested interaction and recommendation order are the product direction. +Because the repository classifies `AppSettings.swift` as a high-risk control +surface, an independent verifier must review the diff and rerun the risk-critical +checks before merge. PR approval and any protected production action remain +human gates; this implementation records no approval. diff --git a/docs/sdlc/changes/2026-08-30-model-type-selection/spec.md b/docs/sdlc/changes/2026-08-30-model-type-selection/spec.md new file mode 100644 index 00000000..e28d93d2 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-model-type-selection/spec.md @@ -0,0 +1,54 @@ +# Spec: Make custom models an explicit recommended-first type + +## Context + +`selectedModelFamily` currently uses `nil` implicitly for family-less models, +while the picker maps `nil` back to Qwen. `localLLMModelsSection` renders the +custom list and add/import controls after every family. Speech choices use +`SpeechEngineType.allCases` order after filtering MiMo. + +## Design + +Define one ordered formatting presentation enum with five cases: Qwen, Gemma, +Llama, Remote, and Custom. The Qwen case exposes a recommendation flag and a +picker title containing the localized recommended marker. Local-family cases +map to `ModelCatalog.ModelFamily`; Custom maps to the existing `nil` family; +Remote maps to `settings.useRemoteLLM`. + +The segmented picker remains one row in the grouped form. It uses the full +available section width, native compact height, and no nested background. +Custom content uses the same 8/12-point internal rhythm as current model rows. + +`syncSelectedFamilyFromActiveModel()` assigns the active entry's family even +when it is `nil`, making family-less custom/imported models select Custom. +Browsing types does not change `settings.llmModel`. Leaving Remote preserves +the existing conditional load callback for the currently active local type. + +Speech presentation order becomes explicit and independent from persisted enum +declaration order: Qwen, Whisper, Apple, Doubao. Qwen's title includes the same +localized recommendation marker. + +## Safety and failure modes + +- A fifth formatting segment can clip in English. Real-window verification + checks both languages; labels remain short and use the native control. +- `nil` must mean Custom only in this UI selection layer; catalog model-family + semantics remain unchanged. +- Switching from Remote to Custom must not load a Qwen model accidentally. +- Existing custom models must remain visible and actionable under Custom. + +## Test strategy + +- Focused tests assert both explicit type orders and Qwen recommendation state. +- Full tests cover settings persistence and model catalog invariants. +- Repository SDLC/basic checks and a verified app build must pass. +- Real-window checks cover both selectors and Custom content across Chinese/ + English and light/dark appearances. + +## Rollout and rollback + +Merge only after independent high-risk verification, visual approval, and the +normal PR gate. If type selection changes the active model or labels clip, +revert the seven governed source/localization files and their focused test; +persisted settings remain readable because raw values and model IDs did not +change. No data migration or production rollout action is needed. diff --git a/docs/sdlc/changes/2026-08-30-model-type-selection/state.json b/docs/sdlc/changes/2026-08-30-model-type-selection/state.json new file mode 100644 index 00000000..7d042914 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-model-type-selection/state.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "id": "2026-08-30-model-type-selection", + "title": "Make custom models an explicit recommended-first type", + "risk": "high", + "status": "verified", + "owners": [ + "repository maintainer" + ], + "acceptanceCriteria": [ + "Text formatting types appear as Qwen recommended, Gemma, Llama, Remote, and Custom in that order.", + "Custom model entries, model ID input, and local import appear only when Custom is selected.", + "An active custom or imported local model selects Custom instead of falling back to Qwen.", + "Speech engine types put Qwen recommended first, followed by Whisper, Apple, and Doubao.", + "The real fixed-size window is inspected in Chinese and English, in light and dark appearances, without clipped type labels." + ], + "governedPaths": [ + "Sources/Config/AppSettings.swift", + "Sources/UI/ModelManagementEnginePicker.swift", + "Sources/UI/ModelManagementFamilies.swift", + "Sources/UI/ModelManagementRows.swift", + "Sources/UI/ModelManagementSections.swift", + "Sources/Resources/en.lproj/Localizable.strings", + "Sources/Resources/zh-Hans.lproj/Localizable.strings", + "Tests/OpenTypeTests/ConfigurationTests.swift" + ], + "artifacts": { + "intent": "intent.md", + "spec": "spec.md", + "plan": "plan.md", + "verification": "verification.md" + } +} diff --git a/docs/sdlc/changes/2026-08-30-model-type-selection/verification.md b/docs/sdlc/changes/2026-08-30-model-type-selection/verification.md new file mode 100644 index 00000000..ec5583ce --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-model-type-selection/verification.md @@ -0,0 +1,48 @@ +# Verification: Make custom models an explicit recommended-first type + +## Evidence + +- The focused regression loop initially failed on the old presentation order: + formatting exposed no Custom case or recommended Qwen state, and speech put + Qwen after Whisper, Apple, and Doubao. After implementation, + `swift test --filter ConfigurationTests` passed 34 tests. +- `swift test` passed 565 tests with 8 intentionally skipped integration tests + and no failures. +- `bash scripts/ci-basic-checks.sh` passed, including SDLC validation, + localization plist linting and key parity, deterministic vocabulary checks, + conflict-marker checks, and secret-bearing file checks. +- `bash scripts/build-and-run.sh --verify` built, bundled, signed, launched, and + detected the final app after the temporary QA launch hook had been removed. +- Real-window inspection covered Chinese and English in light and dark + appearances. Both selectors stayed within the fixed 760-point window. The + initial full English `Recommended` label exposed horizontal overflow; the + final localized type marker is `Rec.` in English and `推荐` in Chinese. +- In the live Chinese light window, selecting Custom removed all Qwen entries + and displayed only the custom model ID field, Add button, and local import. +- `python3 scripts/sdlc.py validate --worktree` and `git diff --check` passed. + +## Acceptance criteria + +- Explicit Custom formatting type — verified in code, focused tests, and the + live window. +- Recommended-first Qwen order in both selectors — verified in focused tests + and both localized live windows. +- Active custom-model synchronization — verified by assigning the catalog + entry's optional family directly; family-less entries now map to Custom. +- Chinese/English light/dark real-window inspection — verified without clipped + labels or content overflow. + +## Residual risk + +The segmented-control recommendation is deliberately abbreviated to `Rec.` in +English to fit five equal-width native segments. The full `Recommended` text +continues to appear on individual recommended model rows. The repository's +path policy classifies the `AppSettings.swift` order change as high risk, so an +independent verifier, final visual approval, PR approval, and any protected +production action remain external gates. + +## Decision + +Ready for independent and human review. The implementation evidence is current, +and no setting raw values, model IDs, catalog contents, or inference behavior +changed. No independent or human approval is claimed here. diff --git a/docs/sdlc/changes/2026-08-30-settings-window-layout/intent.md b/docs/sdlc/changes/2026-08-30-settings-window-layout/intent.md new file mode 100644 index 00000000..5da3501b --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-settings-window-layout/intent.md @@ -0,0 +1,49 @@ +# Intent: Stabilize the settings window and shared layout + +## Problem + +The settings window can be resized even though its dense tabbed forms were +designed around one working size. At wider and restored sizes, grouped forms and +custom scroll content no longer share predictable horizontal edges. The first +pass also used decorative page illustrations that were later removed in favor +of native grouped forms. + +## Outcome + +The Utter settings window always opens at one predictable size and cannot be +resized. Native settings pages use grouped form geometry, while Activity keeps a +shared 28-point content inset for its data presentation. + +## Scope + +In scope: settings-window size and style, shared settings-page spacing, grouped +form content margins, removal of decorative page illustrations, and focused +regression coverage. + +Out of scope: individual setting behavior, tab order, copy, app icon/menu-bar +icon choices, onboarding, the recording overlay, packaging, and release policy. + +## Constraints + +- Preserve the existing six tabs and lock the window to the prior 760-point + minimum width and 680-point default height. +- Use semantic system colors and native controls that remain legible in light, + dark, and increased-contrast appearances. +- Keep existing raster resources available so this UI-only change does not + broaden into package-resource cleanup. +- Treat the fixed size as an explicit product exception for this single-purpose + settings utility, not a new rule for other app windows. + +## Acceptance criteria + +- The settings content area is exactly 760 by 680 points and the window style + does not permit resizing. +- Settings pages use native grouped-form margins, while Activity data cards and + history records share a 28-point horizontal content edge. +- No settings page loads or presents a decorative page-header illustration. +- The real settings window is inspected in Chinese and English, in both light + and dark appearance, with all tabs free of clipping or alignment regressions. + +## Open questions + +None. Human review still owns final visual acceptance. diff --git a/docs/sdlc/changes/2026-08-30-settings-window-layout/plan.md b/docs/sdlc/changes/2026-08-30-settings-window-layout/plan.md new file mode 100644 index 00000000..234d81a0 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-settings-window-layout/plan.md @@ -0,0 +1,27 @@ +# Plan: Stabilize the settings window and shared layout + +## Work items + +- [x] Record the fixed shell and shared inner-grid constraints. +- [x] Make the AppKit and SwiftUI settings surfaces use one exact content size. +- [x] Align native grouped forms and Activity data content to their shared grid. +- [x] Remove decorative page-header illustrations from the final settings UI. +- [x] Add focused fixed-window regression coverage. +- [x] Inspect every tab in the real fixed-size window in both languages and + appearances. +- [x] Record current verification evidence and residual risk. + +## Verification plan + +- [x] `python3 scripts/sdlc.py validate --worktree` +- [x] `bash scripts/ci-basic-checks.sh` +- [x] `swift test` +- [x] `bash scripts/build-and-run.sh --verify` +- [x] Real-window Chinese/English and light/dark inspection of all six tabs +- [x] `git diff --check` + +## Human gates + +The requested fixed-window behavior is the product direction. A reviewer must +still approve the visual result and the intentional macOS window-management +exception before merge; no approval is recorded by this implementation. diff --git a/docs/sdlc/changes/2026-08-30-settings-window-layout/spec.md b/docs/sdlc/changes/2026-08-30-settings-window-layout/spec.md new file mode 100644 index 00000000..8d6b10b9 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-settings-window-layout/spec.md @@ -0,0 +1,47 @@ +# Spec: Stabilize the settings window and shared layout + +## Context + +`AppDelegate.openSettings()` creates a resizable AppKit window while +`SettingsView` only defines minimum dimensions. Custom scroll content and +grouped `Form` tabs currently inherit different outer content margins, while +the native grouped form settles on a predictable reading width at the compact +window size. Decorative page illustrations add a second visual hierarchy that +does not belong in the final native-form direction. + +## Design + +The settings shell has one geometry contract: a 760 by 680 content area, no +`.resizable` style mask, and equal minimum and maximum content sizes. The root +SwiftUI view uses the same exact frame so restored AppKit window state cannot +produce a second layout width. + +Native settings tabs use grouped `Form` and `Section` geometry as their source +of truth. Activity data cards and history record cards keep a shared 28-point +content inset. Decorative page headers and illustrations are removed rather +than replaced with another custom icon treatment. + +## Safety and failure modes + +- Fixed sizing can expose clipping that resizing previously hid. The retained + scroll views and form scrolling contain long content; every tab is inspected + at the fixed size in both supported UI languages. +- Removing the resize style must not remove close or minimize behavior. Those + style masks remain present. +- Semantic colors and native controls keep the interface compatible with dark + mode and accessibility contrast. + +## Test strategy + +- Unit coverage asserts the exact content dimensions and absence of the + `.resizable` style mask. +- Repository policy, localization, compilation, and the full Swift test suite + exercise the implementation. +- A real built app verifies the fixed window geometry, disabled resize/zoom + affordance, all six tabs, Chinese/English copy, and light/dark rendering. + +## Rollout and rollback + +Merge through normal PR review. Stop if any tab clips at 760 by 680 or if the +window can still be resized after relaunch/restored state. Roll back by reverting +this bundle and its UI changes; no user data or setting persistence changes. diff --git a/docs/sdlc/changes/2026-08-30-settings-window-layout/state.json b/docs/sdlc/changes/2026-08-30-settings-window-layout/state.json new file mode 100644 index 00000000..2c61e85f --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-settings-window-layout/state.json @@ -0,0 +1,35 @@ +{ + "schemaVersion": 1, + "id": "2026-08-30-settings-window-layout", + "title": "Stabilize the settings window and shared layout", + "risk": "medium", + "status": "verified", + "owners": [ + "repository maintainer" + ], + "acceptanceCriteria": [ + "The settings content area is exactly 760 by 680 points and the window style does not permit resizing.", + "Settings pages use native grouped-form margins, while Activity data cards and history records share a 28-point horizontal content edge.", + "No settings page loads or presents a decorative page-header illustration.", + "The real settings window is inspected in Chinese and English, in both light and dark appearance, with all tabs free of clipping or alignment regressions." + ], + "governedPaths": [ + "Sources/App/OpenTypeApp.swift", + "Sources/UI/SettingsView.swift", + "Sources/UI/SettingsVoiceIllustration.swift", + "Sources/UI/GeneralSettingsView.swift", + "Sources/UI/IntegrationsSettingsView.swift", + "Sources/UI/HistoryInsightsOverview.swift", + "Sources/UI/HistoryRecordsView.swift", + "Sources/UI/ModelManagementView.swift", + "Sources/UI/DictionaryStyleView.swift", + "Sources/UI/AboutView.swift", + "Tests/OpenTypeTests/ConfigurationTests.swift" + ], + "artifacts": { + "intent": "intent.md", + "spec": "spec.md", + "plan": "plan.md", + "verification": "verification.md" + } +} diff --git a/docs/sdlc/changes/2026-08-30-settings-window-layout/verification.md b/docs/sdlc/changes/2026-08-30-settings-window-layout/verification.md new file mode 100644 index 00000000..1ae5e5f0 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-settings-window-layout/verification.md @@ -0,0 +1,34 @@ +# Verification: Stabilize the settings window and shared layout + +## Evidence + +| Check | Result | Evidence | +|---|---|---| +| `python3 scripts/sdlc.py validate --worktree` | Pass | Validated all four change bundles after this bundle advanced to `verified` | +| `bash scripts/ci-basic-checks.sh` | Pass | Package, SDLC harness, plist, localization, brand, resource, vocabulary, secret-file, and symlink checks passed | +| `swift test` | Pass | 564 XCTest cases passed, 8 environment-gated cases skipped, and the Swift Testing model-upgrade case passed | +| Focused settings tests | Pass | Exact 760 by 680 content size and non-resizable style mask passed | +| `bash scripts/build-and-run.sh --verify` | Pass | Final code rebuilt, bundled, and signed locally | +| Real-window visual inspection | Pass | All six tabs inspected at 760 by 680 in Chinese and English, in both light and dark appearances; native grouping, copy, scroll containment, and contrast passed; Accessibility reported the zoom button disabled | +| `git diff --check` | Pass | No whitespace errors | + +## Acceptance criteria + +- Fixed 760 by 680 non-resizable settings window — pass in unit coverage and + the real AppKit window; zoom was disabled in every inspected tab. +- Native settings geometry plus a shared 28-point Activity/history content edge + — pass in the compact real window. +- Decorative page-header removal — pass; the earlier vector illustration pass + was superseded by the accepted native grouped-form direction. +- Chinese/English light/dark real-window validation — pass across all six tabs, + including fixed-width English tab labels and scrollable long-form content. + +## Residual risk + +The six legacy PNG illustration resources remain packaged but are no longer +loaded by settings pages. Resource cleanup is intentionally outside this UI-only +change. Visual acceptance remains a human review decision. + +## Decision + +Ready for review. Human approval is recorded separately. diff --git a/docs/sdlc/changes/2026-08-30-system-settings-groups/intent.md b/docs/sdlc/changes/2026-08-30-system-settings-groups/intent.md new file mode 100644 index 00000000..327a35c6 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-system-settings-groups/intent.md @@ -0,0 +1,46 @@ +# Intent: Use native System Settings form structure + +## Problem + +The first flattening pass still simulates System Settings with custom white +cards, thin outlines, large icon tiles, and a promotional page header. In the +real fixed-size window it remains visually much farther from macOS System +Settings than the native grouped forms already used by General and Integrations. + +## Outcome + +Settings pages use actual grouped `Form` and `Section` containers so macOS owns +their fill, corner shape, row height, separators, and header placement. The +decorative page illustration and oversized custom selection tiles are removed. + +## Scope + +In scope: the settings-page shell and section composition in General, Style and +Rules, Models, Integrations, and About; the Activity-page decorative header; +and oversized custom choices for style, speech engine, and model family. + +Out of scope: setting behavior, copy, tab navigation, fixed window geometry, +history metric cards and charts, data persistence, and release policy. + +## Constraints + +- Preserve the existing fixed 760 by 680 settings window. +- Use semantic system colors and native controls in light, dark, and + increased-contrast appearances. +- Preserve all setting actions, model load/unload callbacks, and state bindings. +- Do not force history charts and metric cards into a settings form. + +## Acceptance criteria + +- General, Models, Style, Integrations, and About use native grouped `Form` and + `Section` surfaces rather than custom card simulations. +- Settings pages do not show the decorative 96-point page header, illustration, + or accessory badge. +- Speech-engine, language-style, and model-family choices use compact native + controls instead of large icon tiles. +- The real fixed-size settings window is inspected in Chinese and English, in + light and dark appearances, without clipping or hierarchy regressions. + +## Open questions + +None. Human review retains final visual approval. diff --git a/docs/sdlc/changes/2026-08-30-system-settings-groups/plan.md b/docs/sdlc/changes/2026-08-30-system-settings-groups/plan.md new file mode 100644 index 00000000..3cb4ea17 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-system-settings-groups/plan.md @@ -0,0 +1,25 @@ +# Plan: Use native System Settings form structure + +## Work items + +- [x] Record the rejected custom-card result and native Form target. +- [x] Remove decorative page headers from settings pages and Activity. +- [x] Convert Models, Style, and About to native grouped forms. +- [x] Replace large style, speech-engine, and family tiles with compact native controls. +- [x] Run automated checks and Chinese/English light/dark real-window verification. +- [x] Record current verification evidence and residual risk. + +## Verification plan + +- [x] `python3 scripts/sdlc.py validate --worktree` +- [x] `bash scripts/ci-basic-checks.sh` +- [x] `swift test` +- [x] `bash scripts/build-and-run.sh --verify` +- [x] Real-window Chinese/English light/dark inspection of all six tabs +- [x] `git diff --check` + +## Human gates + +The requested System Settings fidelity is the product direction. A reviewer +must still approve the real-window result before merge; this implementation does +not record human approval. diff --git a/docs/sdlc/changes/2026-08-30-system-settings-groups/spec.md b/docs/sdlc/changes/2026-08-30-system-settings-groups/spec.md new file mode 100644 index 00000000..b9a0e983 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-system-settings-groups/spec.md @@ -0,0 +1,59 @@ +# Spec: Use native System Settings form structure + +## Context + +The rejected pass still owns section surfaces in custom SwiftUI code. It also +keeps a 96-point page header, a 72-point illustration, and 44-point icon choice +tiles. These choices dominate the visual hierarchy and prevent the result from +matching macOS System Settings even when the section title sits outside. + +## Layout specification + +The fixed settings shell remains unchanged. Settings content uses one native +single-column grid: + +- `Form` with `.formStyle(.grouped)` owns content margins and vertical rhythm; +- `Section` owns external headers, semantic group fill, corner shape, and row + separators; +- standard macOS controls remain at native compact height; +- long editors and data lists may define content height, but do not draw an + additional section-level rounded rectangle; +- Activity metrics retain their chart/card layout because they present data, + while their decorative page illustration header is removed. + +This replaces the earlier hand-tuned 28/14/8 custom-card grid. The system form +is the geometry source of truth, so new settings do not invent new margins, +corner radii, or section spacing. + +## Design + +Delete the custom section simulator from settings-page composition. General, +Models, Style, Integrations, and About render their content directly in grouped +forms. Page titles remain discoverable through the selected tab and window; +duplicate promotional headers and illustrations are removed. + +Replace the style cards and model engine/family icon strips with segmented +pickers backed by the existing settings bindings and selection callbacks. +Activity keeps its segmented Insights/History control in a compact top strip. + +## Safety and failure modes + +- Grouped Form can impose different spacing than custom scroll views. The fixed + window is inspected in both languages and appearances. +- Replacing custom choices must preserve unload/load callbacks for remote and + local model-family changes. +- Long model and dictionary lists must remain scroll-contained and reachable. + +## Test strategy + +- Compile and run the full Swift test suite to catch view/type regressions. +- Run repository SDLC, localization, and invariant checks. +- Build the application and inspect all six tabs in the real fixed-size window + in Chinese and English, in light and dark appearances. + +## Rollout and rollback + +Merge through normal PR review. Stop if controls clip, a model-selection +callback changes, or grouped sections regress in either appearance. Roll back +by reverting this bundle and its view composition changes; no data migration is +involved. diff --git a/docs/sdlc/changes/2026-08-30-system-settings-groups/state.json b/docs/sdlc/changes/2026-08-30-system-settings-groups/state.json new file mode 100644 index 00000000..b6f09b1b --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-system-settings-groups/state.json @@ -0,0 +1,40 @@ +{ + "schemaVersion": 1, + "id": "2026-08-30-system-settings-groups", + "title": "Use native System Settings form structure", + "risk": "medium", + "status": "verified", + "owners": [ + "repository maintainer" + ], + "acceptanceCriteria": [ + "General, Models, Style, Integrations, and About use native grouped Form and Section surfaces rather than custom card simulations.", + "Settings pages do not show the decorative 96-point page header, illustration, or accessory badge.", + "Speech-engine, language-style, and model-family choices use compact native controls instead of large icon tiles.", + "The real fixed-size settings window is inspected in Chinese and English, in light and dark appearances, without clipping or hierarchy regressions." + ], + "governedPaths": [ + "Sources/UI/SettingsVoiceIllustration.swift", + "Sources/UI/HistoryStatsView.swift", + "Sources/UI/HistoryInsightsOverview.swift", + "Sources/UI/HistoryRecordsView.swift", + "Sources/UI/GeneralSettingsView.swift", + "Sources/UI/DictionaryStyleView.swift", + "Sources/UI/IndustryLexiconView.swift", + "Sources/UI/DictionaryManagementView.swift", + "Sources/UI/ModelManagementView.swift", + "Sources/UI/ModelManagementActions.swift", + "Sources/UI/ModelManagementEnginePicker.swift", + "Sources/UI/ModelManagementFamilies.swift", + "Sources/UI/ModelManagementRows.swift", + "Sources/UI/ModelManagementSections.swift", + "Sources/UI/AboutView.swift", + "Sources/UI/IntegrationsSettingsView.swift" + ], + "artifacts": { + "intent": "intent.md", + "spec": "spec.md", + "plan": "plan.md", + "verification": "verification.md" + } +} diff --git a/docs/sdlc/changes/2026-08-30-system-settings-groups/verification.md b/docs/sdlc/changes/2026-08-30-system-settings-groups/verification.md new file mode 100644 index 00000000..a88f0903 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-system-settings-groups/verification.md @@ -0,0 +1,33 @@ +# Verification: Use native System Settings form structure + +## Evidence + +| Check | Result | Evidence | +|---|---|---| +| `python3 scripts/sdlc.py validate --worktree` | Pass | All four change bundles validated after the current bundle advanced to `verified` | +| `bash scripts/ci-basic-checks.sh` | Pass | SDLC harness, plist, localization, vocabulary, resource, secret-file, and symlink checks passed | +| `swift test` | Pass | 564 XCTest cases passed, 8 environment-gated cases skipped, and the Swift Testing model-upgrade case passed | +| `bash scripts/build-and-run.sh --verify` | Pass | The final code compiled, assembled, and signed the local app bundle | +| Real-window visual inspection | Pass | All six tabs were inspected at 760 by 680 in Chinese and English, in both light and dark appearances; grouped surfaces, external titles, scrolling, copy, and contrast remained intact | +| `git diff --check` | Pass | No whitespace errors | + +## Acceptance criteria + +- Native grouped forms — pass; General, Models, Style, Integrations, and About + render settings in grouped `Form` and `Section` surfaces. +- Decorative page-header removal — pass; the 96-point header, 72-point + illustration, page badge, panel, and section-wrapper types were removed. +- Compact native choices — pass; speech engine, language style, and model family + use segmented pickers while preserving their existing bindings and callbacks. +- Chinese/English light/dark real-window inspection — pass across all six tabs. + +## Residual risk + +Long model and dictionary lists remain denser than ordinary system preference +rows because they expose real managed data. They remain inside a single native +section surface and scroll correctly. Final visual approval remains a human +review decision. + +## Decision + +Ready for review. Human approval is recorded separately. From e712b876af21b7ffa763ed1874df1f66ef05c2c1 Mon Sep 17 00:00:00 2001 From: idevlab Date: Sun, 30 Aug 2026 15:53:43 +0800 Subject: [PATCH 2/2] fix: restore icon in development app bundles --- .../intent.md | 35 ++++++++++++++ .../2026-08-30-dev-app-icon-packaging/plan.md | 24 ++++++++++ .../2026-08-30-dev-app-icon-packaging/spec.md | 36 ++++++++++++++ .../state.json | 25 ++++++++++ .../verification.md | 42 ++++++++++++++++ scripts/build-and-run.sh | 48 +++++++------------ 6 files changed, 178 insertions(+), 32 deletions(-) create mode 100644 docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/intent.md create mode 100644 docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/plan.md create mode 100644 docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/spec.md create mode 100644 docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/state.json create mode 100644 docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/verification.md diff --git a/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/intent.md b/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/intent.md new file mode 100644 index 00000000..77f77467 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/intent.md @@ -0,0 +1,35 @@ +# Intent: Restore the Dock icon in development app bundles + +## Problem + +`scripts/build-and-run.sh` writes a reduced `Info.plist` without app-icon keys +and leaves `AppIcon.icns` nested inside a SwiftPM resource bundle. When the app +uses its default system icon preference, AppKit clears the runtime override and +Dock has no bundle icon to display. + +## Outcome + +Development app bundles use the canonical metadata and expose the pre-generated +icon files at the bundle root expected by Dock and LaunchServices. + +## Scope + +In scope: development app assembly and deterministic icon checks. Out of scope: +icon artwork, release packaging, signing identities, and runtime icon settings. + +## Constraints + +- Preserve the current fast SwiftPM development build. +- Do not weaken or bypass existing code signing. +- Reuse canonical metadata and generated icon assets instead of duplicating them. + +## Acceptance criteria + +- The development plist declares `CFBundleIconFile=AppIcon`. +- `Contents/Resources/AppIcon.icns` exists and is non-empty. +- Missing icon metadata or the root icon makes packaging fail. +- The signed development app still launches successfully. + +## Open questions + +None. The release builder already establishes the expected bundle contract. diff --git a/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/plan.md b/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/plan.md new file mode 100644 index 00000000..976a908a --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/plan.md @@ -0,0 +1,24 @@ +# Plan: Restore the Dock icon in development app bundles + +## Work items + +- [x] Reproduce the missing icon metadata and root resource in the built app. +- [x] Compare development and release bundle assembly. +- [x] Reuse canonical metadata and generated icon assets. +- [x] Re-run the exact package invariant and application launch checks. +- [x] Record repository-level validation and residual risk. + +## Verification plan + +- [x] `bash scripts/build-and-run.sh --verify` +- [x] Packaged plist and root-icon invariant check +- [x] `codesign --verify --deep --strict dist/Utter.app` +- [x] `python3 scripts/sdlc.py validate --worktree` +- [x] `bash scripts/ci-basic-checks.sh` +- [x] `swift test` +- [x] `git diff --check` + +## Human gates + +PR review owns acceptance of the development bundle change. No signing identity, +tag, release, or protected production action is performed by this work. diff --git a/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/spec.md b/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/spec.md new file mode 100644 index 00000000..6c5635c1 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/spec.md @@ -0,0 +1,36 @@ +# Spec: Restore the Dock icon in development app bundles + +## Context + +The release builder copies `Resources/Info.plist` and compiles/copies the app +icon into `Contents/Resources`. The development builder instead synthesized a +second plist and copied only SwiftPM resource bundles, allowing the two bundle +contracts to drift. + +## Design + +Copy the canonical repository `Info.plist` into the development bundle. Copy +the existing light, dark, and default `.icns` files to the root Resources +directory before signing. After signing, assert that `CFBundleIconFile` equals +`AppIcon` and that the default root icon is non-empty before any run mode opens +the app. + +## Safety and failure modes + +- The canonical plist adds the same bundle metadata used in releases; no + permission or identity value is changed. +- A missing generated icon now fails closed instead of launching an iconless app. +- The pre-generated `.icns` files do not require the slower release asset-catalog + compilation, keeping the development loop fast. + +## Test strategy + +Run the original red package-invariant command, `build-and-run.sh --verify`, +code-sign verification, the repository basic checks, and the full Swift tests. +Inspect the packaged plist and root icon directly. + +## Rollout and rollback + +Use the corrected script for local development builds after review. Revert this +script and bundle if canonical metadata affects development-only startup; the +release pipeline is unchanged. diff --git a/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/state.json b/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/state.json new file mode 100644 index 00000000..da6f90b0 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/state.json @@ -0,0 +1,25 @@ +{ + "schemaVersion": 1, + "id": "2026-08-30-dev-app-icon-packaging", + "title": "Restore the Dock icon in development app bundles", + "risk": "medium", + "status": "verified", + "owners": [ + "repository maintainer" + ], + "acceptanceCriteria": [ + "The development Utter.app declares AppIcon through the canonical Info.plist.", + "The development bundle contains a root Contents/Resources/AppIcon.icns file for Dock and LaunchServices.", + "The development packaging command fails before launch when either icon invariant is missing.", + "Existing build, signing, helper, resource-bundle, and launch behavior remains intact." + ], + "governedPaths": [ + "scripts/build-and-run.sh" + ], + "artifacts": { + "intent": "intent.md", + "spec": "spec.md", + "plan": "plan.md", + "verification": "verification.md" + } +} diff --git a/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/verification.md b/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/verification.md new file mode 100644 index 00000000..aa77e816 --- /dev/null +++ b/docs/sdlc/changes/2026-08-30-dev-app-icon-packaging/verification.md @@ -0,0 +1,42 @@ +# Verification: Restore the Dock icon in development app bundles + +## Evidence + +- The pre-fix package invariant failed deterministically: the development plist + had no `CFBundleIconFile` and `Contents/Resources/AppIcon.icns` was absent. +- `DEVELOPER_DIR=/Applications/Xcode-beta.app/Contents/Developer bash + scripts/build-and-run.sh --verify` built, signed, launched, and detected the + corrected development app. +- The same package invariant now reports `CFBundleIconFile=AppIcon`, finds the + non-empty root icon, and returns PASS. +- `codesign --verify --deep --strict --verbose=2 dist/Utter.app` passed. The + packaged and source `AppIcon.icns` files share SHA-256 + `ecb90dc46a59f11944074e8b6bd22dd52a261bf8f0264e4c6bd00876b6b6a70f`. +- The packaged `.icns` was rendered to PNG and visually inspected as the + intended Utter microphone icon. +- `bash scripts/ci-basic-checks.sh` passed all SDLC, localization, identifier, + resource, conflict, secret-file, symlink, and deterministic lexicon checks. +- `swift test` passed 565 XCTest tests with 8 intentional skips and no failures; + the Swift Testing model-upgrade policy test also passed. +- `bash -n scripts/build-and-run.sh`, `python3 scripts/sdlc.py validate + --worktree`, and `git diff --check` passed. + +## Acceptance criteria + +- Canonical icon metadata — pass. +- Root Dock icon resource — pass and byte-identical to the source asset. +- Fail-closed packaging invariant — pass; every development run mode checks the + plist value and root icon before launching. +- Signed app launch — pass. + +## Residual risk + +The fast development builder copies pre-generated `.icns` files rather than +compiling the release `Assets.car`. The default Dock/LaunchServices icon is now +restored, while the existing runtime PNG path continues to handle explicit +light and dark icon preferences. The release pipeline is unchanged. + +## Decision + +Verified and ready for PR review. No signing identity, release tag, or production +authorization was changed. diff --git a/scripts/build-and-run.sh b/scripts/build-and-run.sh index 88e0196a..4827458e 100755 --- a/scripts/build-and-run.sh +++ b/scripts/build-and-run.sh @@ -7,9 +7,7 @@ set -euo pipefail MODE="${1:-run}" APP_NAME="Utter" BUILD_PRODUCT="OpenType" -BUNDLE_ID="com.opentype.voiceinput" CLI_HELPER_NAME="opentype-cli" -MIN_SYSTEM_VERSION="26.0" SUBSYSTEM="com.opentype.voiceinput" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -21,6 +19,7 @@ APP_RESOURCES="$APP_CONTENTS/Resources" APP_BINARY="$APP_MACOS/$APP_NAME" CLI_HELPER_BINARY="$APP_MACOS/$CLI_HELPER_NAME" INFO_PLIST="$APP_CONTENTS/Info.plist" +ICON_RESOURCES="$ROOT_DIR/Sources/Resources" export CLANG_MODULE_CACHE_PATH="${CLANG_MODULE_CACHE_PATH:-/tmp/clang-module-cache}" mkdir -p "$CLANG_MODULE_CACHE_PATH" @@ -43,36 +42,11 @@ if [ -x "$CLI_BUILD_BINARY" ]; then chmod +x "$CLI_HELPER_BINARY" fi -cat >"$INFO_PLIST" < - - - - CFBundleExecutable - $APP_NAME - CFBundleIdentifier - $BUNDLE_ID - CFBundleName - $APP_NAME - CFBundlePackageType - APPL - LSMinimumSystemVersion - $MIN_SYSTEM_VERSION - LSUIElement - - NSMicrophoneUsageDescription - Utter needs microphone access to capture voice for transcription. - NSSpeechRecognitionUsageDescription - Utter uses speech recognition to convert voice to text. - NSAppleEventsUsageDescription - Utter needs automation access to type text into other applications. - NSScreenCaptureUsageDescription - Utter uses screen content for context-aware text correction. - NSPrincipalClass - NSApplication - - -PLIST +cp "$ROOT_DIR/Resources/Info.plist" "$INFO_PLIST" + +for icon in AppIcon.icns AppIconLight.icns AppIconDark.icns; do + cp "$ICON_RESOURCES/$icon" "$APP_RESOURCES/$icon" +done for bundle in "$BUILD_DIR"/*.bundle; do [ -d "$bundle" ] || continue @@ -97,6 +71,16 @@ else codesign --force --deep --sign - --entitlements "$ENTITLEMENTS" "$APP_BUNDLE" fi +icon_file="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$INFO_PLIST" 2>/dev/null || true)" +[ "$icon_file" = "AppIcon" ] || { + echo "error: development app is missing CFBundleIconFile=AppIcon" >&2 + exit 1 +} +[ -s "$APP_RESOURCES/AppIcon.icns" ] || { + echo "error: development app is missing Contents/Resources/AppIcon.icns" >&2 + exit 1 +} + open_app() { /usr/bin/open -n "$APP_BUNDLE" }