From 133578eda9577a0c335f2994bb7d2e6b222897cf Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 29 Sep 2026 07:56:17 -0700 Subject: [PATCH 1/6] Revert "Add "Speak all punctuation" mode to format spoken marks (#207)" This reverts commit 6d0ba47f54fc8e4f3ad35571b6d231a5a180b02f. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 16 +- App/Blurt/Blurt/Wizard/DeveloperSection.swift | 40 +--- .../Blurt/Wizard/SettingsWindowRoot.swift | 80 ++++--- .../Blurt/Wizard/TextShortcutsSection.swift | 19 -- App/Blurt/BlurtUITests/README.md | 3 +- App/Blurt/BlurtUITests/SettingsUITests.swift | 22 -- App/Blurt/Shared/UITestIdentifiers.swift | 1 - Sources/BlurtEngine/Config/DefaultsKey.swift | 2 - .../Config/EnhancedTranscriptsStore.swift | 9 - .../Config/SpokenPunctuationStore.swift | 34 --- .../Injection/KeyInjector+Separator.swift | 10 +- .../Pipeline/DictationSession+Pipeline.swift | 8 +- .../Pipeline/DictationSession.swift | 50 ++--- .../Pipeline/SpokenPunctuationFormatter.swift | 212 ------------------ Sources/BlurtEngine/README.md | 5 +- .../STT/AssemblyAITranscriber.swift | 2 +- .../EnhancedTranscriptsStoreTests.swift | 12 - .../KeyInjectorLeadingSeparatorTests.swift | 7 - .../PersistedSettingsTests.swift | 9 +- .../SpokenPunctuationTests.swift | 146 ------------ .../Stubs/DictationSessionTestSupport.swift | 5 +- 21 files changed, 89 insertions(+), 603 deletions(-) delete mode 100644 Sources/BlurtEngine/Config/SpokenPunctuationStore.swift delete mode 100644 Sources/BlurtEngine/Pipeline/SpokenPunctuationFormatter.swift delete mode 100644 Tests/BlurtEngineTests/SpokenPunctuationTests.swift diff --git a/AGENTS.md b/AGENTS.md index ad0b6656..44bb6ad0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -576,9 +576,8 @@ verbatim transcript, all inside the same request, and it rides **every** request **The switch is applied to the response, not the request** (since 2026-09-11). The response carries both `text` (the verbatim transcript, "never altered by the LLM") and `llm_response` (the rewrite), so `AssemblyAITranscriber.transcript(from:)` simply picks: with **enhanced -transcripts** on (`EnhancedTranscriptsStore.pastesRewrite`, on by default and off while "speak all -punctuation" is on, read per request via the transcriber's injected `enhancedTranscripts` closure) -it prefers the rewrite and falls back to +transcripts** on (`EnhancedTranscriptsStore`, on by default, read per request via the +transcriber's injected `enhancedTranscripts` closure) it prefers the rewrite and falls back to `text` when `llm_response` is null or blank — the rewrite is best-effort (5 s server-side budget), so a rewrite failure (`llm_error`) is a logged degradation, never a user-facing error. With the setting off it returns `text`, and the rewrite that came back is discarded unread. @@ -956,16 +955,7 @@ Engine-side stores, all `UserDefaults`-backed value types with the same shape: pairs; re-read per transcript via the session's `textShortcutsProvider` and applied locally by `TextShortcutExpander` between the response and the paste — never on the wire. Like `StyleProfileStore` it has a setter, because the value is encoded; the Settings window's Text - Shortcuts pane writes through it), - **`SpokenPunctuationStore`** (`BlurtSpokenPunctuation`, off by default — the "speak all - punctuation" mode, re-read per transcript via the session's `spokenPunctuationProvider`. While - on, `SpokenPunctuationFormatter` strips every mark the service inserted and turns the spoken - ones — "comma", "question mark", "new paragraph" — back into symbols, locally, before - `TextShortcutExpander` runs, so a shortcut's saved text keeps its own punctuation. It formats the - **verbatim** transcript, never the rewrite, since the rewrite is told to fix punctuation and may - already have turned a spoken "comma" into a mark the strip would remove: so it overrides - enhanced transcripts and the active style, through the one rule both read, - `EnhancedTranscriptsStore.pastesRewrite`. The request is unchanged). + Shortcuts pane writes through it). - **`DefaultsKey`** (`Config/DefaultsKey.swift`) defines every key those stores write, one case each, and each store's `defaultsKey` reads its case from there rather than spelling a string literal. **`PersistedSettings.allDefaultsKeys`** is therefore just `DefaultsKey.allCases`, and diff --git a/App/Blurt/Blurt/Wizard/DeveloperSection.swift b/App/Blurt/Blurt/Wizard/DeveloperSection.swift index 472ca8c7..9f26ce54 100644 --- a/App/Blurt/Blurt/Wizard/DeveloperSection.swift +++ b/App/Blurt/Blurt/Wizard/DeveloperSection.swift @@ -3,9 +3,9 @@ import BlurtEngine import OSLog import SwiftUI -// The Advanced pane's three standalone sections: the update check, the -// developer-mode switch and the start-over button. All are Settings-only (none -// gates setup, so none is a wizard step), and they live here rather than in +// The Advanced pane's two standalone sections: the developer-mode switch and +// the start-over button. Both are Settings-only (neither gates setup, so +// neither is a wizard step), and they live here rather than in // `SettingsWindowRoot` because that file is at the repo's file-length limit. /// The Developer section of the Settings window: an opt-in switch for developer @@ -194,37 +194,3 @@ struct ResetSection: View { NSApp.terminate(nil) } } - -/// The Updates section of the Settings window: the running version and a -/// "Check for Updates" button that runs the check and reports the result in a -/// modal (see `UpdateCheckModel`). The same check is reachable from the -/// "Check for Updates…" app-menu command and the menu-bar item; all three share -/// the one `UpdateCheckModel` owned by `AppDelegate`, so a check from any place -/// runs through the same controller. -struct UpdateSection: View { - let model: UpdateCheckModel - - var body: some View { - Section { - // "Blurt 0.1.31" — the label is the engine's (shared with the result - // alerts, so the two can't name the version differently). - SettingRow(title: model.versionLabel, systemImage: "arrow.triangle.2.circlepath") { - HStack(spacing: 8) { - // A user-initiated check that can stall on a slow connection needs - // visible progress, or the button reads as dead until the result - // alert lands. Show a spinner and disable the button while in flight - // (the model already ignores a second check) — the native equivalent - // of Sparkle's "Checking for updates…". - if model.isChecking { - ProgressView().controlSize(.small) - } - Button("Check for Updates") { model.checkForUpdates() } - .disabled(model.isChecking) - .accessibilityIdentifier(UITestIdentifiers.updateCheck) - } - } - } header: { - Text("Updates") - } - } -} diff --git a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift index 7e102ced..14e7ae08 100644 --- a/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift +++ b/App/Blurt/Blurt/Wizard/SettingsWindowRoot.swift @@ -106,46 +106,30 @@ private struct AdvancedSettingsTab: View { /// switch. Every dictation request asks AssemblyAI's dictation API for its /// server-side cleanup rewrite, so the response always holds both versions; /// while this is on (the default) the polished one is pasted, and turned off -/// the verbatim transcript is pasted exactly as spoken. "Speak all punctuation" -/// (on the Text Shortcuts pane) formats the verbatim transcript itself, so it -/// overrides this switch, which is disabled while it is on -/// (`EnhancedTranscriptsStore.pastesRewrite`). The transcriber reads the same -/// default this toggle writes at every request, so a change applies to the next -/// dictation. Settings-only — not a wizard step, since it never gates setup. -/// -/// Nothing here may grow the pane: Advanced is a non-scrolling form, and one -/// extra row here was enough to push its Reset button out of reach on the CI -/// runner's display (`testResetAsksBeforeDoingAnything`, "Not hittable"). +/// the verbatim transcript is pasted exactly as spoken. The transcriber reads +/// the same default this toggle writes at every request, so a change applies to +/// the next dictation — see `AssemblyAITranscriber.transcript(from:)`. +/// Settings-only — not a wizard step, since it never gates setup. private struct TranscriptionSection: View { // The unset default comes from the store, not a literal here: the transcriber // reads the same slot per request, and two spellings of "unset means on" would let // the toggle and the request disagree about an untouched install. @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue - @AppStorage(SpokenPunctuationStore.defaultsKey) private var spokenPunctuation = false var body: some View { Section { Toggle(isOn: $enhancedTranscripts) { Label("Enhanced transcripts", systemImage: "wand.and.stars") } - .disabled(spokenPunctuation) - .help(spokenPunctuation ? "Off while Speak all punctuation is on." : "") .accessibilityIdentifier(UITestIdentifiers.enhancedTranscriptsToggle) } header: { Text("Transcription") } footer: { - Text(footer) - } - } - - private var footer: String { - if spokenPunctuation { - return "Off while Speak all punctuation is on, which pastes your words as spoken with only " - + "the punctuation you say." + Text( + "Polishes each dictation before pasting — removing filler words and fixing punctuation. " + + "Turn off to paste your words exactly as spoken.") } - return "Polishes each dictation before pasting — removing filler words and fixing punctuation. " - + "Turn off to paste your words exactly as spoken." } } @@ -155,8 +139,8 @@ private struct TranscriptionSection: View { /// request (see `CleanupInstruction.sendable(appending:)` / `StyleProfileStore`), /// so the enhanced-transcript polish also applies the user's formatting /// preferences. Optional — with none defined the request is exactly what ships -/// today. Disabled while enhanced transcripts are off (or overridden by spoken -/// punctuation), since the rewrite they shape is not pasted then. +/// today. Disabled while enhanced transcripts are off, since the instruction +/// they extend is not sent at all then. /// /// Each row is a name and a way in: all editing happens in the sheet below, for /// the reasons on `APIKeyStepView`'s. Which style is *active* is deliberately @@ -166,10 +150,6 @@ private struct TranscriptionSection: View { private struct StyleProfilesSection: View { @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue - @AppStorage(SpokenPunctuationStore.defaultsKey) private var spokenPunctuation = false - - /// The view-side spelling of `EnhancedTranscriptsStore.pastesRewrite`. - private var stylesApply: Bool { enhancedTranscripts && !spokenPunctuation } /// Bound to observe, not to write: the store owns the JSON encoding, so it /// decodes this slot and the sheet writes through it, while `@AppStorage` is @@ -205,13 +185,11 @@ private struct StyleProfilesSection: View { // enhanced transcripts off the rewrite a style shapes is discarded // unread, so describing the limit is the less useful half. Text( - stylesApply + enhancedTranscripts ? "Up to \(StyleProfileStore.profileLimit) styles." - : spokenPunctuation - ? "Styles don’t apply while Speak all punctuation is on." - : "Style preferences need enhanced transcripts turned on.") + : "Style preferences need enhanced transcripts turned on.") } - .disabled(!stylesApply) + .disabled(!enhancedTranscripts) .sheet(item: $editing) { profile in StyleProfileEditorSheet(profile: profile, isExisting: profiles.contains(profile)) } @@ -386,3 +364,37 @@ private struct StyleProfileEditorSheet: View { dismiss() } } + +/// The Updates section of the Settings window: the running version and a +/// "Check for Updates" button that runs the check and reports the result in a +/// modal (see `UpdateCheckModel`). The same check is reachable from the +/// "Check for Updates…" app-menu command and the menu-bar item; all three share +/// the one `UpdateCheckModel` owned by `AppDelegate`, so a check from any place +/// runs through the same controller. +private struct UpdateSection: View { + let model: UpdateCheckModel + + var body: some View { + Section { + // "Blurt 0.1.31" — the label is the engine's (shared with the result + // alerts, so the two can't name the version differently). + SettingRow(title: model.versionLabel, systemImage: "arrow.triangle.2.circlepath") { + HStack(spacing: 8) { + // A user-initiated check that can stall on a slow connection needs + // visible progress, or the button reads as dead until the result + // alert lands. Show a spinner and disable the button while in flight + // (the model already ignores a second check) — the native equivalent + // of Sparkle's "Checking for updates…". + if model.isChecking { + ProgressView().controlSize(.small) + } + Button("Check for Updates") { model.checkForUpdates() } + .disabled(model.isChecking) + .accessibilityIdentifier(UITestIdentifiers.updateCheck) + } + } + } header: { + Text("Updates") + } + } +} diff --git a/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift b/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift index 4783c237..52e1d3c4 100644 --- a/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift +++ b/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift @@ -9,17 +9,10 @@ import SwiftUI /// /// Rows follow the Styles section's shape: each is a read-out plus an "Edit…" /// way in, and all editing happens in the sheet below. -/// -/// The "Speak all punctuation" switch sits above the list: it is the other -/// local rewrite of what was said (`SpokenPunctuationFormatter`, run just -/// before the shortcuts), and this is the one pane that scrolls — the Advanced -/// pane, beside the enhanced-transcripts switch it overrides, hugs its content -/// and had no height to spare (see `TranscriptionSection`). struct TextShortcutsSection: View { /// Bound to observe, not to write — the store owns the JSON encoding, as with /// `StyleProfilesSection`. @AppStorage(TextShortcutStore.defaultsKey) private var rawShortcuts = "" - @AppStorage(SpokenPunctuationStore.defaultsKey) private var spokenPunctuation = false /// The shortcut the sheet is editing, or nil while it's closed. @State private var editing: TextShortcut? @@ -27,18 +20,6 @@ struct TextShortcutsSection: View { var body: some View { let shortcuts = TextShortcutStore().shortcuts(decoding: rawShortcuts) Form { - Section { - Toggle(isOn: $spokenPunctuation) { - Label("Speak all punctuation", systemImage: "quote.opening") - } - .accessibilityIdentifier(UITestIdentifiers.spokenPunctuationToggle) - } header: { - Text("Punctuation") - } footer: { - Text( - "Punctuation appears only where you say it — “comma”, “period”, “question mark”, " - + "“new line”. Overrides Enhanced transcripts.") - } Section { ForEach(Array(shortcuts.enumerated()), id: \.element.id) { index, shortcut in HStack(spacing: 12) { diff --git a/App/Blurt/BlurtUITests/README.md b/App/Blurt/BlurtUITests/README.md index 6cf5a8ec..598d6266 100644 --- a/App/Blurt/BlurtUITests/README.md +++ b/App/Blurt/BlurtUITests/README.md @@ -8,8 +8,7 @@ observable UI rather than mocking views. - **Settings page** — `SettingsUITests`: the real Settings window's API-key flow (save / reject / reveal, and the change → cancel round-trip back to the saved - row), the hotkey picker, the sound picker, and "Speak all punctuation" (on - the Text Shortcuts pane) disabling the enhanced-transcripts switch it overrides. + row), the hotkey picker, and the sound picker. - **Text shortcuts** — `TextShortcutsUITests`: the Settings pane's add / edit / delete round-trip through the editor sheet, and a duplicate phrase (differing only in case and separators) leaving Save disabled. diff --git a/App/Blurt/BlurtUITests/SettingsUITests.swift b/App/Blurt/BlurtUITests/SettingsUITests.swift index 6b9306a0..87540254 100644 --- a/App/Blurt/BlurtUITests/SettingsUITests.swift +++ b/App/Blurt/BlurtUITests/SettingsUITests.swift @@ -122,28 +122,6 @@ final class SettingsUITests: BlurtUITestCase { XCTAssertEqual("\(toggle.value ?? "")", "1", "Clicking should switch developer mode on") } - /// "Speak all punctuation" (Text Shortcuts pane) starts off, and switching it - /// on disables the enhanced-transcripts switch it overrides on the Advanced - /// pane (`EnhancedTranscriptsStore.pastesRewrite`). - func testSpokenPunctuationDisablesEnhancedTranscripts() { - let settings = openSettingsWindow() - let shortcuts = selectSettingsTab(settings, named: UITestIdentifiers.textShortcutsTab) - - let toggle = shortcuts.anyDescendant(identified: UITestIdentifiers.spokenPunctuationToggle) - XCTAssertTrue(toggle.waitForExistence(timeout: 10), "Speak all punctuation toggle not found") - XCTAssertEqual("\(toggle.value ?? "")", "0", "Speak all punctuation should start switched off") - - toggle.click() - XCTAssertEqual("\(toggle.value ?? "")", "1", "Clicking should switch spoken punctuation on") - - // Switched from `shortcuts`, not `settings`: selecting a pane retitles the - // window, so the proxy from before the first switch is stale. - let advanced = selectSettingsTab(shortcuts, named: UITestIdentifiers.advancedSettingsTab) - let enhanced = advanced.anyDescendant(identified: UITestIdentifiers.enhancedTranscriptsToggle) - XCTAssertTrue(enhanced.waitForExistence(timeout: 10), "Enhanced transcripts toggle not found") - XCTAssertFalse(enhanced.isEnabled, "Spoken punctuation overrides enhanced transcripts") - } - /// The Advanced pane's "Check for Updates" button runs the check and reports /// the result in a modal. Under UI testing the check is stubbed offline to /// always report up-to-date, so clicking it surfaces the "You’re up to date" diff --git a/App/Blurt/Shared/UITestIdentifiers.swift b/App/Blurt/Shared/UITestIdentifiers.swift index 58c1847a..b344f551 100644 --- a/App/Blurt/Shared/UITestIdentifiers.swift +++ b/App/Blurt/Shared/UITestIdentifiers.swift @@ -67,7 +67,6 @@ enum UITestIdentifiers { static let soundPicker = "settings.sound.picker" static let developerToggle = "settings.developer.toggle" static let enhancedTranscriptsToggle = "settings.enhancedTranscripts.toggle" - static let spokenPunctuationToggle = "settings.spokenPunctuation.toggle" static let updateCheck = "settings.update.check" /// The Advanced pane's "Reset…" button (`SettingsWindowRoot`'s reset section). /// Only the row button is identified: the confirmation it opens is an alert, diff --git a/Sources/BlurtEngine/Config/DefaultsKey.swift b/Sources/BlurtEngine/Config/DefaultsKey.swift index 3ce2c832..7850d08a 100644 --- a/Sources/BlurtEngine/Config/DefaultsKey.swift +++ b/Sources/BlurtEngine/Config/DefaultsKey.swift @@ -48,8 +48,6 @@ enum DefaultsKey: String, CaseIterable { case micDeviceUID = "MicDeviceUID" /// The JSON list of spoken-phrase → text expansions (`TextShortcutStore`). case textShortcuts = "TextShortcuts" - /// The "speak all punctuation" switch (`SpokenPunctuationStore`). - case spokenPunctuation = "SpokenPunctuation" /// The key this case actually reads and writes, under the configured host /// identity. A computed property rather than a stored string because the diff --git a/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift b/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift index c86b7b14..4b385401 100644 --- a/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift +++ b/Sources/BlurtEngine/Config/EnhancedTranscriptsStore.swift @@ -39,13 +39,4 @@ public struct EnhancedTranscriptsStore { var isEnabled: Bool { defaults.object(forKey: Self.defaultsKey) as? Bool ?? Self.defaultValue } - - /// Whether the rewrite is what gets pasted: this switch, unless "speak all - /// punctuation" is on — that mode formats the verbatim transcript, since the - /// rewrite is told to fix punctuation and may already have turned a spoken - /// "comma" into a mark the formatter would then strip (`SpokenPunctuationStore`). - /// The one rule both the transcriber and the Recent row's style chip read. - var pastesRewrite: Bool { - isEnabled && !SpokenPunctuationStore(defaults: defaults).isEnabled - } } diff --git a/Sources/BlurtEngine/Config/SpokenPunctuationStore.swift b/Sources/BlurtEngine/Config/SpokenPunctuationStore.swift deleted file mode 100644 index fe9b43d0..00000000 --- a/Sources/BlurtEngine/Config/SpokenPunctuationStore.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation - -/// Persists the "speak all punctuation" switch in `UserDefaults`. Off by -/// default; the Settings window's Text Shortcuts pane flips it. -/// -/// While on, the pipeline pastes only the punctuation the user *said*: every -/// mark the service inserted is stripped and spoken ones ("comma", "question -/// mark", "new paragraph") are put back as symbols — see -/// `SpokenPunctuationFormatter`. The verbatim transcript is formatted rather -/// than the cleanup rewrite, which is told to fix punctuation and so may turn a -/// spoken "comma" into a mark the formatter would then strip; so this mode -/// overrides enhanced transcripts, and with them the active style -/// (`EnhancedTranscriptsStore.pastesRewrite`). -/// Read at every dictation, so a change applies to the next one. Same shape as -/// `DeveloperModeStore`. -public struct SpokenPunctuationStore { - /// UserDefaults key holding the switch. Public so SwiftUI views can observe - /// it directly (e.g. `@AppStorage`) and re-render on change. - public static var defaultsKey: String { DefaultsKey.spokenPunctuation.key } - private let defaults: UserDefaults - - init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - /// `bool(forKey:)` returns false for a missing key, so unset means off. - /// - /// Read-only for the reason on `DeveloperModeStore.isEnabled`: the Settings - /// toggle writes the slot through `@AppStorage`, so a setter here would have - /// no production caller. Seed the slot to change the switch. - var isEnabled: Bool { - defaults.bool(forKey: Self.defaultsKey) - } -} diff --git a/Sources/BlurtEngine/Injection/KeyInjector+Separator.swift b/Sources/BlurtEngine/Injection/KeyInjector+Separator.swift index 5e691301..79aec915 100644 --- a/Sources/BlurtEngine/Injection/KeyInjector+Separator.swift +++ b/Sources/BlurtEngine/Injection/KeyInjector+Separator.swift @@ -20,21 +20,13 @@ extension KeyInjector { /// chunks where nothing strips it. `priorText` is nil for empty fields and for /// secure/Accessibility-opaque fields — there we can't tell what precedes the /// caret, so we add no separator rather than risk a stray leading space. - /// - /// Nor before text that opens with a mark that hugs the word before it — a - /// spoken-punctuation dictation of "comma and then" pastes ", and then", which - /// belongs flush against the previous word (`SpokenPunctuationFormatter`). public static func withLeadingSeparator(_ text: String, after priorText: String?) -> String { guard !text.isEmpty else { return text } guard let priorText, let last = priorText.last, !last.isWhitespace else { return text } - guard let first = text.first, !first.isWhitespace, !closingMarks.contains(first) else { return text } + guard let first = text.first, !first.isWhitespace else { return text } return " " + text } - /// Marks that attach to the text before them, so a paste opening with one - /// takes no leading separator. - private static let closingMarks: Set = [".", ",", "?", "!", ":", ";", ")", "]", "…"] - /// Chooses what text the separator decision should treat as preceding the /// caret. AX-read `priorText` is authoritative whenever we have it. When it's /// nil — the field is empty *or* Accessibility-opaque (Electron/Monaco, e.g. VS diff --git a/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift b/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift index bac098e7..ab5af52d 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift @@ -70,11 +70,9 @@ extension DictationSession { if Task.isCancelled { return } // The log keeps what the service returned; the paste and the Recent list - // get the formatted, expanded text, and the ring also keeps the spoken - // version (see `RecentDictations.Entry.spoken`). Spoken punctuation runs - // first so a shortcut's saved text keeps its own punctuation. - let punctuated = spokenPunctuationProvider() ? SpokenPunctuationFormatter.format(text) : text - let expanded = TextShortcutExpander.expand(punctuated, using: textShortcutsProvider()) + // get the expanded text, and the ring also keeps the spoken version (see + // `RecentDictations.Entry.spoken`). + let expanded = TextShortcutExpander.expand(text, using: textShortcutsProvider()) guard let spoken = text.trimmedNonEmpty(), let trimmed = expanded.trimmedNonEmpty() else { setPhase(.idle) return diff --git a/Sources/BlurtEngine/Pipeline/DictationSession.swift b/Sources/BlurtEngine/Pipeline/DictationSession.swift index 5892e74e..0133e646 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession.swift @@ -1,6 +1,7 @@ -// `Dispatch`, not `Foundation`: the only thing here from outside the module is `contextQueue`'s and -// `commandQueue`'s `DispatchQueue`, the same choice `+Press.swift` documents. Foundation's last use -// here went with `mic.stop()` returning a byte count instead of a `Data` blob. +// `Dispatch`, not `Foundation`: the only thing here from outside the module is +// `contextQueue`'s and `commandQueue`'s `DispatchQueue`, the same choice +// `+Press.swift` documents. Foundation's last use here went with `mic.stop()` +// returning a byte count instead of a `Data` blob. import Dispatch import Synchronization @@ -20,12 +21,14 @@ public actor DictationSession { /// the failure from the log. public internal(set) var phase: PipelinePhase = .idle - // Split for the lint file-length budget: `performPress` — the whole press half, including the mic - // bring-up — lives in `+Press`, mirroring the post-release transcribe→inject pipeline in - // `+Pipeline`. `submit(_:)`, both cancel commands and the cancel-intent accessors over `cancelState` - // live in `+Commands`; `phaseStream()`/`setPhase`/os_signpost live in `+Observation`; the - // non-protocol collaborators (focus capture, developer-mode log) live in `+Seams`. Members those - // files reach are internal, not private (file-scoped access can't cross the split), `phase`'s setter too. + // Split for the lint file-length budget: `performPress` — the whole press half, + // including the mic bring-up — lives in `+Press`, mirroring the post-release + // transcribe→inject pipeline in `+Pipeline`. `submit(_:)`, both cancel commands + // and the cancel-intent accessors over `cancelState` live in `+Commands`; + // `phaseStream()`/`setPhase`/os_signpost live in `+Observation`; the + // non-protocol collaborators (focus capture, developer-mode log) live in + // `+Seams`. Members those files reach are internal, not private (file-scoped + // access can't cross the split) — including `phase`'s setter. /// Live feeds of phase changes. Each `phaseStream()` call yields the current /// phase plus every subsequent transition, so the production renderer and @@ -58,8 +61,6 @@ public actor DictationSession { let styleNameProvider: @Sendable () -> String? /// The text shortcuts expanded into each transcript before the paste; live-read. let textShortcutsProvider: @Sendable () -> [TextShortcut] - /// Whether "speak all punctuation" formats each transcript (`SpokenPunctuationStore`); live-read. - let spokenPunctuationProvider: @Sendable () -> Bool /// Auto-releases the hotkey after this long so a held key can't run forever. /// Defaults to just under the dictation API's audio cap (see /// `SyncSTTLimits`) — recording past it would only produce audio the @@ -76,9 +77,10 @@ public actor DictationSession { /// user has spoken a whole utterance. Defaults to always-ready (no Keychain /// read), so tests and keyless hosts are unaffected unless they opt in. let readinessCheck: @Sendable () -> BlurtError? - /// Fired once with the final transcript as soon as it's produced — before injection, so pasted, - /// copied, and failed-to-paste dictations all count. The second argument is `recentDictations` as - /// it stands, pushed from its one owner so the "Recent" list is a projection, not a second ring. + /// Fired once with the final transcript as soon as it's produced — before + /// injection, so pasted, copied, and failed-to-paste dictations all count. The + /// second argument is `recentDictations` as it stands, pushed from its one owner + /// so the "Recent" list is a projection rather than a second ring (see it). let onTranscriptDelivered: (@Sendable (String, RecentDictations) -> Void)? /// The focus capture and the developer-mode log, behind closures rather than @@ -102,7 +104,8 @@ public actor DictationSession { /// copy of that history. Recorded in `runTranscribeInject` (`+Pipeline`) just /// before `onTranscriptDelivered` fires, and read at press time into /// `TranscriptionContext.recentTranscripts`, which sends them as the leading - /// `stt_prompt` lines — so a run of dictations reads as one continuing dialogue. + /// leading `stt_prompt` lines — so a run of dictations reads to the model as + /// one continuing dialogue rather than N unrelated clips. /// /// It lives here, not in the host, because the request is assembled inside this /// actor: a ring held as MainActor UI state couldn't be read at press time @@ -177,15 +180,14 @@ public actor DictationSession { keyTermsProvider: (@Sendable () -> [String])? = nil, styleNameProvider: (@Sendable () -> String?)? = nil, textShortcutsProvider: (@Sendable () -> [TextShortcut])? = nil, - spokenPunctuationProvider: (@Sendable () -> Bool)? = nil, readinessCheck: @escaping @Sendable () -> BlurtError? = { nil }, onTranscriptDelivered: (@Sendable (String, RecentDictations) -> Void)? = nil ) { self.init( mic: mic, transcriber: transcriber, injector: injector, - maxRecordingSeconds: maxRecordingSeconds, clock: clock, keyTermsProvider: keyTermsProvider, - styleNameProvider: styleNameProvider, textShortcutsProvider: textShortcutsProvider, - spokenPunctuationProvider: spokenPunctuationProvider, readinessCheck: readinessCheck, + maxRecordingSeconds: maxRecordingSeconds, clock: clock, + keyTermsProvider: keyTermsProvider, styleNameProvider: styleNameProvider, + textShortcutsProvider: textShortcutsProvider, readinessCheck: readinessCheck, onTranscriptDelivered: onTranscriptDelivered, seams: .production) } @@ -204,7 +206,6 @@ public actor DictationSession { keyTermsProvider: (@Sendable () -> [String])? = nil, styleNameProvider: (@Sendable () -> String?)? = nil, textShortcutsProvider: (@Sendable () -> [TextShortcut])? = nil, - spokenPunctuationProvider: (@Sendable () -> Bool)? = nil, readinessCheck: @escaping @Sendable () -> BlurtError? = { nil }, onTranscriptDelivered: (@Sendable (String, RecentDictations) -> Void)? = nil, seams: Seams @@ -218,15 +219,14 @@ public actor DictationSession { self.styleNameProvider = styleNameProvider ?? { - // No rewrite pasted means no style was applied — matching the - // transcriber's own per-request read of the same rule. `active` is nil - // for the Default sentinel as well as for an empty list, which is exactly + // No rewrite means no style was applied — matching the transcriber's + // own per-request read of the same store. `active` is nil for the + // Default sentinel as well as for an empty list, which is exactly // the rule: only a *custom* style is worth naming on the row. - guard EnhancedTranscriptsStore().pastesRewrite else { return nil } + guard EnhancedTranscriptsStore().isEnabled else { return nil } return StyleProfileStore().active?.name } self.textShortcutsProvider = textShortcutsProvider ?? { TextShortcutStore().shortcuts } - self.spokenPunctuationProvider = spokenPunctuationProvider ?? { SpokenPunctuationStore().isEnabled } self.readinessCheck = readinessCheck self.onTranscriptDelivered = onTranscriptDelivered self.seams = seams diff --git a/Sources/BlurtEngine/Pipeline/SpokenPunctuationFormatter.swift b/Sources/BlurtEngine/Pipeline/SpokenPunctuationFormatter.swift deleted file mode 100644 index f1d4980d..00000000 --- a/Sources/BlurtEngine/Pipeline/SpokenPunctuationFormatter.swift +++ /dev/null @@ -1,212 +0,0 @@ -/// The "speak all punctuation" pass (`SpokenPunctuationStore`): strips every -/// mark the service put in a transcript, then turns the punctuation the user -/// *said* back into symbols — "hello comma how are you question mark" → -/// "hello, how are you?". Pure and local, like `TextShortcutExpander`, and run -/// before it so a shortcut's saved text is pasted with its own punctuation. -/// -/// - **What is stripped**: sentence and phrase punctuation — `. , ? ! : ;`, -/// quotes, brackets, dashes, ellipses, `¿ ¡`. Symbols someone may well have -/// dictated as words (`% $ @ & # /`) stay. A `.` `,` `:`, apostrophe or hyphen -/// *between* two letters or digits is part of a word and stays too: "don't", -/// "well-known", "3.5", "1,000", "10:30", "cal.com". -/// - **What is recognized**: the phrases in `vocabulary`, case-insensitively and -/// as whole words. Deliberately literal — in this mode "period" is always a -/// `.`, the trade every spoken-punctuation mode makes. Bare "quote" and "dot" -/// are left out as too common as ordinary words. -/// - **Spacing** follows the mark: closing marks hug the word before, opening -/// ones the word after, hyphens and dashes both, and a line break takes the -/// spaces on either side. -/// - **Case**: a word after a spoken sentence end or line break is capitalized. -/// A word the service capitalized only because *its* period came first — -/// "Hello comma. How are you" — is lowercased again, unless it doesn't look -/// like a sentence-start capital ("I", "I'm", "NASA", "iPhone"). A name right -/// after a stripped period loses its capital too; that is the price of not -/// pasting "Hello, How are you?". -enum SpokenPunctuationFormatter { - /// How a mark sits against the words either side of it. - enum Spacing { - /// Hugs the word before it: `.` `,` `)`. - case closing - /// Hugs the word after it: `(` and an opening quote. - case opening - /// Hugs both: a hyphen, a dash, an apostrophe. - case joining - /// A line break: no space on either side. - case lineBreak - } - - struct Mark { - let symbol: String - let spacing: Spacing - /// Whether the word after it starts a sentence, and so is capitalized. - let endsSentence: Bool - } - - /// One recognized phrase: its words, spelled as `matchKey` reads them, and - /// the mark it stands for. - struct Entry { - let words: [String] - let mark: Mark - } - - /// A word left once the service's punctuation is stripped. - struct Word { - let text: String - /// Whether a sentence end the *service* inserted came right before it — - /// the case where its capital letter may be an artifact of that period. - let followsStrippedSentenceEnd: Bool - } - - enum Piece { - case word(Word) - case mark(Mark) - } - - /// Every spoken phrase the formatter recognizes, longest first so "question - /// mark" claims its words before any shorter phrase could. - static let vocabulary: [Entry] = { - func entries( - _ phrases: [String], _ symbol: String, _ spacing: Spacing, endsSentence: Bool = false - ) -> [Entry] { - let mark = Mark(symbol: symbol, spacing: spacing, endsSentence: endsSentence) - return phrases.map { Entry(words: $0.split(separator: " ").map(String.init), mark: mark) } - } - let groups = [ - entries(["period", "full stop"], ".", .closing, endsSentence: true), - entries(["comma"], ",", .closing), - entries(["question mark"], "?", .closing, endsSentence: true), - entries(["exclamation point", "exclamation mark"], "!", .closing, endsSentence: true), - entries(["colon"], ":", .closing), - entries(["semicolon", "semi colon"], ";", .closing), - entries(["ellipsis", "dot dot dot"], "…", .closing), - entries(["hyphen"], "-", .joining), - entries(["dash", "em dash"], "—", .joining), - entries(["apostrophe"], "'", .joining), - entries(["open paren", "open parenthesis", "left paren", "left parenthesis"], "(", .opening), - entries(["close paren", "close parenthesis", "right paren", "right parenthesis"], ")", .closing), - entries(["open bracket", "left bracket"], "[", .opening), - entries(["close bracket", "right bracket"], "]", .closing), - entries(["open quote", "begin quote", "start quote"], "\"", .opening), - entries(["close quote", "end quote", "unquote"], "\"", .closing), - entries(["new line", "newline"], "\n", .lineBreak, endsSentence: true), - entries(["new paragraph"], "\n\n", .lineBreak, endsSentence: true), - ] - return groups.flatMap { $0 }.sorted { $0.words.count > $1.words.count } - }() - - /// Stripped wherever they sit — inside a token too, where they split it: - /// "yes—no" is two words. - private static let alwaysStripped: Set = [ - "?", "!", ";", "\"", "“", "”", "„", "‘", "«", "»", "‹", "›", "(", ")", "[", "]", "{", "}", - "…", "—", "–", "¿", "¡", - ] - - /// Stripped from either end of a word, but kept between two of its letters - /// or digits: "don't", "well-known", "3.5", "1,000", "10:30". - private static let wordJoiners: Set = [".", ",", ":", "'", "’", "-", "‐"] - - /// The stripped marks that end a sentence, for `Word.followsStrippedSentenceEnd`. - private static let sentenceEnds: Set = [".", "?", "!", "…"] - - static func format(_ text: String) -> String { - render(pieces(from: strippedWords(in: text))) - } - - /// A token as `vocabulary` spells it: lowercased letters only, so "Comma,", - /// "semi-colon" and "new-line" all read as vocabulary words. - static func matchKey(_ word: String) -> String { - word.lowercased().filter(\.isLetter) - } - - /// `text`'s words with the service's punctuation gone. - static func strippedWords(in text: String) -> [Word] { - var words: [Word] = [] - var afterSentenceEnd = false - func add(_ piece: String) { - let trailing = piece.reversed().prefix(while: wordJoiners.contains) - let core = piece.drop(while: wordJoiners.contains).dropLast(trailing.count) - if !core.isEmpty { - words.append(Word(text: String(core), followsStrippedSentenceEnd: afterSentenceEnd)) - afterSentenceEnd = false - } - if trailing.contains(".") { afterSentenceEnd = true } - } - for token in text.split(whereSeparator: \.isWhitespace) { - var piece = "" - for character in token { - guard alwaysStripped.contains(character) else { - piece.append(character) - continue - } - add(piece) - piece = "" - if sentenceEnds.contains(character) { afterSentenceEnd = true } - } - add(piece) - } - return words - } - - /// `words` with each spoken phrase in `vocabulary` replaced by its mark. - static func pieces(from words: [Word]) -> [Piece] { - let keys = words.map { matchKey($0.text) } - var pieces: [Piece] = [] - var index = 0 - while index < words.count { - if let entry = vocabulary.first(where: { keys[index...].starts(with: $0.words) }) { - pieces.append(.mark(entry.mark)) - index += entry.words.count - } else { - pieces.append(.word(words[index])) - index += 1 - } - } - return pieces - } - - static func render(_ pieces: [Piece]) -> String { - var output = "" - var previous: Piece? - var capitalizeNext = false - for piece in pieces { - if let previous, spaceAfter(previous), spaceBefore(piece) { output += " " } - switch piece { - case .word(let word): - output += cased(word, capitalize: capitalizeNext) - capitalizeNext = false - case .mark(let mark): - output += mark.symbol - capitalizeNext = capitalizeNext || mark.endsSentence - } - previous = piece - } - return output - } - - private static func spaceAfter(_ piece: Piece) -> Bool { - guard case .mark(let mark) = piece else { return true } - return mark.spacing == .closing - } - - private static func spaceBefore(_ piece: Piece) -> Bool { - guard case .mark(let mark) = piece else { return true } - return mark.spacing == .opening - } - - private static func cased(_ word: Word, capitalize: Bool) -> String { - let text = word.text - if capitalize { return text.prefix(1).uppercased() + text.dropFirst() } - guard word.followsStrippedSentenceEnd, isSentenceCapitalized(text) else { return text } - return text.prefix(1).lowercased() + text.dropFirst() - } - - /// "Hello", but not "I", "I'm", "NASA" or "iPhone" — the shape a capital - /// from sentence position takes, as opposed to one the word always carries. - private static func isSentenceCapitalized(_ text: String) -> Bool { - guard let first = text.first, first.isUppercase else { return false } - let rest = text.dropFirst() - if rest.contains(where: \.isUppercase) { return false } - let isPronounI = first == "I" && (rest.isEmpty || rest.first == "'" || rest.first == "’") - return !isPronounI - } -} diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 185e2afe..4ab3fc86 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -77,7 +77,7 @@ press() ──▶ MicCapture.start() → frames release() ──▶ MicCapture Key properties of the design, which your integration can rely on: - **One request per utterance, chunked upload, whole response.** The dictation API returns the complete transcript — and its LLM-rewritten form — in the response body: no job polling, no incremental deltas, no second request for the cleanup. `TranscriberProtocol.transcribe` is a single `async throws -> String`, so UIs should still show a "transcribing…" state and then the whole result; there is nothing to stream _back_. What is streamed is the request: `transcribe(frames:sampleRate:context:)` is called at press and uploads the recording in chunks as it is captured, so only inference on the last frames lands on the wait the user sits through. There is deliberately no buffered fallback — a body that has already been streamed cannot be replayed, and the transcriber refuses `URLSession`'s replay request rather than silently re-sending an empty one. -- **Cleanup happens server-side, and the opt-out is on the response.** The request's `config.llm_instruction` asks the service to apply our own cleanup instruction (`CleanupInstruction.text` — delete disfluencies, change nothing else) to the verbatim transcript inside the same call. It is one of three steering fields on the request: `config.stt_prompt` primes the _transcription_ with the text that came before — the user's recent dictations, then the text before the cursor (`STTPrompt`, omitted when there is neither) — and `config.keyterms_prompt` boosts the user's key terms (`KeytermsBoost`, omitted when there are none). `prompt` is `stt_prompt`'s alias and `conversation_context` the array-of-turns field it replaced; a request carries exactly one of the three. The instruction rides **every** request; the **enhanced transcripts** setting (`EnhancedTranscriptsStore`, on by default) then decides which of the two transcripts in the response gets pasted (`AssemblyAITranscriber.transcript(from:)`) — unless **speak all punctuation** (`SpokenPunctuationStore`, off by default) is on, which always takes the verbatim `text` and formats it locally (`SpokenPunctuationFormatter`: the service's marks stripped, the spoken ones put back), since the rewrite may already have turned a spoken "comma" into a mark the strip would remove (`EnhancedTranscriptsStore.pastesRewrite`). That ordering is the route's own advice: it rewrites by default, omitting `llm_instruction` only selects the service's wording, and the reference documents no way to decline — the escape it names is to ignore `llm_response` and use `text`. The undocumented `"llm": null` does suppress it (measured 2026-09-10) and Blurt sent it until 2026-09-11; the trade for dropping it is that "off" still waits out and still pays for a rewrite nobody reads. The **active style profile**'s instructions (`StyleProfileStore`, none by default) are appended to that instruction via `CleanupInstruction.sendable(appending:)`, trimmed to the headroom the API's 2048 instruction cap leaves (measured in UTF-8 bytes, the conservative bound — the cap's own unit is unmeasured); no profile, or blank instructions, means the base instruction goes out unchanged. Only the **active** profile's text is ever sent — never a join of the user's profiles, which would blow that cap and 400 the whole request rather than degrading. With the setting on the engine pastes `llm_response`, falling back to the verbatim `text` when the best-effort rewrite failed (`llm_error`) — a degradation, never a user-facing error; with it off it pastes `text` and discards the rewrite unread. There is no client-side LLM pass, no styling stage, and deliberately no hook for one. +- **Cleanup happens server-side, and the opt-out is on the response.** The request's `config.llm_instruction` asks the service to apply our own cleanup instruction (`CleanupInstruction.text` — delete disfluencies, change nothing else) to the verbatim transcript inside the same call. It is one of three steering fields on the request: `config.stt_prompt` primes the _transcription_ with the text that came before — the user's recent dictations, then the text before the cursor (`STTPrompt`, omitted when there is neither) — and `config.keyterms_prompt` boosts the user's key terms (`KeytermsBoost`, omitted when there are none). `prompt` is `stt_prompt`'s alias and `conversation_context` the array-of-turns field it replaced; a request carries exactly one of the three. The instruction rides **every** request; the **enhanced transcripts** setting (`EnhancedTranscriptsStore`, on by default) then decides which of the two transcripts in the response gets pasted (`AssemblyAITranscriber.transcript(from:)`). That ordering is the route's own advice: it rewrites by default, omitting `llm_instruction` only selects the service's wording, and the reference documents no way to decline — the escape it names is to ignore `llm_response` and use `text`. The undocumented `"llm": null` does suppress it (measured 2026-09-10) and Blurt sent it until 2026-09-11; the trade for dropping it is that "off" still waits out and still pays for a rewrite nobody reads. The **active style profile**'s instructions (`StyleProfileStore`, none by default) are appended to that instruction via `CleanupInstruction.sendable(appending:)`, trimmed to the headroom the API's 2048 instruction cap leaves (measured in UTF-8 bytes, the conservative bound — the cap's own unit is unmeasured); no profile, or blank instructions, means the base instruction goes out unchanged. Only the **active** profile's text is ever sent — never a join of the user's profiles, which would blow that cap and 400 the whole request rather than degrading. With the setting on the engine pastes `llm_response`, falling back to the verbatim `text` when the best-effort rewrite failed (`llm_error`) — a degradation, never a user-facing error; with it off it pastes `text` and discards the rewrite unread. There is no client-side LLM pass, no styling stage, and deliberately no hook for one. - **Latency is pre-paid where possible, and never faked.** `press()` claims `.connecting` before it touches the mic, so a host's pill answers the keypress — but `MicCapture.start()` deliberately holds until the input device is actually delivering frames, and the _start cue_ waits for `.recording`. On a Bluetooth route those are ~1–2 s apart and the OS captures nothing in between, so cueing at the press loses the first words. Route activation itself cannot be pre-paid — measured, not assumed: nothing opens the device before `record()`, so the connecting window is where that cost lives and the gate is what makes it honest. `press()` fires a detached `warmUp()` at the transcriber (pre-opening the HTTPS connection so the request it opens moments later streams from its first frame, ~170 ms cold — measured to coalesce with that request rather than race it) and kicks off the cross-process accessibility read of the focused field without awaiting it — the read is then consumed when the _upload_ opens, with a bounded wait (`DictationSession.contextWaitBudget`, 500 ms), because the streaming route needs the `config` part before any audio — so an unresponsive frontmost app costs the transcript its priming, never a multi-second stall, and that wait is spent while the user is still speaking rather than while they wait for a transcript. On the way out, `release()` flips the phase to `.transcribing` _before_ stopping the mic, so a host's stop cue fires at key-up rather than after the Bluetooth tail linger. - **A held trigger auto-releases.** `DictationSession` stops recording after `maxRecordingSeconds` (default `SyncSTTLimits.autoReleaseSeconds`, 115 s) so audio never exceeds what the endpoint accepts, and transcribes what it has. Clips shorter than `SyncSTTLimits.minPCMBytes` (~100 ms of audio — an accidental tap) are dropped as a silent no-op rather than paying for a request that transcribes nothing (measured: the route answers a sub-floor clip with 200 and an empty transcript, not a 400). @@ -243,9 +243,8 @@ Keys are the host identity's `defaultsPrefix` followed by the `DefaultsKey` case | `OverlayOriginStore` | `BlurtOverlayCustomOriginX`/`…Y` | pill never dragged; default place | | `LastUpdateCheckStore` | `BlurtLastUpdateCheck` | never checked | | `TextShortcutStore` | `BlurtTextShortcuts` | no text shortcuts | -| `SpokenPunctuationStore` | `BlurtSpokenPunctuation` | off — punctuation is the model's | -Several are read-only by design (`KeyTermsStore`, `DeveloperModeStore`, `EnhancedTranscriptsStore`, `SpokenPunctuationStore`): the Settings control binds `@AppStorage` to `defaultsKey` and is the sole writer, so normalization lives on the read side — a whitespace-only key-terms field reads back as "no terms" — and a setter here would fight the text field. `StyleProfileStore` is the counter-case: its value is JSON, so the encoding is the store's to own and the views write through it (`profiles`, `activate(_:)`, `activateDefault()` — the last stores a sentinel meaning "base styling, no profile appended", distinct from unset so a migrated legacy profile stays active) while binding `@AppStorage` only to observe. It also still **reads** `BlurtCustomStyle`, the single field that predates profiles, and never writes it — an install with text there and no list reads back as one active profile named "Custom". The six a host constructs itself (`TriggerKeyStore`, `SoundPackStore`, `StyleProfileStore`, `TextShortcutStore`, `OverlayOriginStore`, `LastUpdateCheckStore`) take a `UserDefaults` publicly; the rest keep that injection internal. (`SoundPackStore` also takes the `SoundPackCatalog` it decodes against — see [Record cues](#record-cues).) +Several are read-only by design (`KeyTermsStore`, `DeveloperModeStore`, `EnhancedTranscriptsStore`): the Settings control binds `@AppStorage` to `defaultsKey` and is the sole writer, so normalization lives on the read side — a whitespace-only key-terms field reads back as "no terms" — and a setter here would fight the text field. `StyleProfileStore` is the counter-case: its value is JSON, so the encoding is the store's to own and the views write through it (`profiles`, `activate(_:)`, `activateDefault()` — the last stores a sentinel meaning "base styling, no profile appended", distinct from unset so a migrated legacy profile stays active) while binding `@AppStorage` only to observe. It also still **reads** `BlurtCustomStyle`, the single field that predates profiles, and never writes it — an install with text there and no list reads back as one active profile named "Custom". The six a host constructs itself (`TriggerKeyStore`, `SoundPackStore`, `StyleProfileStore`, `TextShortcutStore`, `OverlayOriginStore`, `LastUpdateCheckStore`) take a `UserDefaults` publicly; the rest keep that injection internal. (`SoundPackStore` also takes the `SoundPackCatalog` it decodes against — see [Record cues](#record-cues).) The keys themselves are one internal `DefaultsKey` enum and each store reads its case from it, so **`PersistedSettings.resetAll(in:)`** sweeps `DefaultsKey.allCases` rather than a hand-maintained list — adding a store and adding it to the reset are not merely the same edit, they're the same line. That's not tidiness: it _was_ a hand-maintained array, and both times a store was copy-edited into existence the second half was forgotten (the overlay origin and the update-check stamp), so a pill dragged during a UI-test run survived `reset-install.sh`'s clean-install path into the next one. Raw values are half the on-disk contract and the identity's `defaultsPrefix` is the other — rename a case freely, never its raw value or your prefix, or every existing user's setting is silently abandoned. diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index 04373e17..052de6a8 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -90,7 +90,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol { self.apiKeyProvider = apiKeyProvider self.baseURL = baseURL self.transport = transport - self.enhancedTranscriptsEnabled = enhancedTranscripts ?? { EnhancedTranscriptsStore().pastesRewrite } + self.enhancedTranscriptsEnabled = enhancedTranscripts ?? { EnhancedTranscriptsStore().isEnabled } self.customStyle = customStyle ?? { StyleProfileStore().activeInstructions } } diff --git a/Tests/BlurtEngineTests/EnhancedTranscriptsStoreTests.swift b/Tests/BlurtEngineTests/EnhancedTranscriptsStoreTests.swift index fc587fd0..7ec04a49 100644 --- a/Tests/BlurtEngineTests/EnhancedTranscriptsStoreTests.swift +++ b/Tests/BlurtEngineTests/EnhancedTranscriptsStoreTests.swift @@ -26,16 +26,4 @@ struct EnhancedTranscriptsStoreTests { defaults.set(true, forKey: EnhancedTranscriptsStore.defaultsKey) #expect(store.isEnabled) } - - /// Spoken punctuation formats the verbatim transcript, so while it is on the - /// rewrite is never what gets pasted, whatever this switch says. - @Test("spoken punctuation overrides the switch") - func spokenPunctuationPastesVerbatim() { - let defaults = freshDefaults() - let store = EnhancedTranscriptsStore(defaults: defaults) - #expect(store.pastesRewrite) - defaults.set(true, forKey: SpokenPunctuationStore.defaultsKey) - #expect(store.isEnabled) - #expect(!store.pastesRewrite) - } } diff --git a/Tests/BlurtEngineTests/KeyInjectorLeadingSeparatorTests.swift b/Tests/BlurtEngineTests/KeyInjectorLeadingSeparatorTests.swift index 4174e0bd..311b9ab5 100644 --- a/Tests/BlurtEngineTests/KeyInjectorLeadingSeparatorTests.swift +++ b/Tests/BlurtEngineTests/KeyInjectorLeadingSeparatorTests.swift @@ -37,13 +37,6 @@ struct KeyInjectorLeadingSeparatorTests { #expect(KeyInjector.withLeadingSeparator(" Second.", after: "First.") == " Second.") } - @Test("no separator before text that opens with a closing mark") - func textStartsWithClosingMark() { - #expect(KeyInjector.withLeadingSeparator(", and then", after: "First") == ", and then") - #expect(KeyInjector.withLeadingSeparator(".", after: "First") == ".") - #expect(KeyInjector.withLeadingSeparator("(aside)", after: "First") == " (aside)") - } - @Test("returns empty text unchanged") func emptyText() { #expect(KeyInjector.withLeadingSeparator("", after: "First.") == "") diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index 6ad38b45..19d1f822 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -41,16 +41,14 @@ struct PersistedSettingsTests { // The text shortcuts: left out, a reset would keep expanding the user's // phrases into their old snippets. #expect(PersistedSettings.allDefaultsKeys.contains(TextShortcutStore.defaultsKey)) - // Spoken punctuation: left out, a reset would keep stripping punctuation. - #expect(PersistedSettings.allDefaultsKeys.contains(SpokenPunctuationStore.defaultsKey)) } @Test("the roster carries no stale or duplicate keys") func rosterHasNoStrays() { - // Exactly the twelve known stores' keys (OverlayOriginStore contributes two, + // Exactly the eleven known stores' keys (OverlayOriginStore contributes two, // StyleProfileStore three): a removed store must leave the roster in the same // change, and a key listed twice would hint at a copy-paste slip. - #expect(PersistedSettings.allDefaultsKeys.count == 15) + #expect(PersistedSettings.allDefaultsKeys.count == 14) #expect(Set(PersistedSettings.allDefaultsKeys).count == PersistedSettings.allDefaultsKeys.count) } @@ -77,12 +75,11 @@ struct PersistedSettingsTests { LastUpdateCheckStore.defaultsKey, MicDeviceStore.defaultsKey, TextShortcutStore.defaultsKey, - SpokenPunctuationStore.defaultsKey, ] #expect(storeKeys == Set(DefaultsKey.allCases.map(\.key))) // No two stores sharing a slot — the Set above would have quietly absorbed a // collision, and two stores on one key means each overwrites the other. - #expect(storeKeys.count == 15) + #expect(storeKeys.count == 14) } @Test("resetAll clears every roster key and leaves unrelated ones alone") diff --git a/Tests/BlurtEngineTests/SpokenPunctuationTests.swift b/Tests/BlurtEngineTests/SpokenPunctuationTests.swift deleted file mode 100644 index 30d2a105..00000000 --- a/Tests/BlurtEngineTests/SpokenPunctuationTests.swift +++ /dev/null @@ -1,146 +0,0 @@ -import Foundation -import Testing - -@testable import BlurtEngine - -@Suite("SpokenPunctuationFormatter") -struct SpokenPunctuationFormatterTests { - private func format(_ text: String) -> String { SpokenPunctuationFormatter.format(text) } - - @Test("strips the service's punctuation and puts back only what was said") - func replacesPunctuation() { - #expect(format("Hello comma, how are you question mark?") == "Hello, how are you?") - #expect(format("Hello, how are you?") == "Hello how are you") - } - - /// The service capitalizes after the periods *it* inserts, which the strip - /// removes — so "How" is lowercased again, while a spoken period capitalizes. - @Test("recases around stripped and spoken sentence ends") - func recases() { - #expect(format("Hello comma. How are you.") == "Hello, how are you") - #expect(format("done period thanks") == "done. Thanks") - #expect(format("Fine. I agree. NASA called. iPhone ships.") == "Fine I agree NASA called iPhone ships") - } - - @Test("marks sit against their neighbours by kind") - func spacing() { - #expect(format("he said open quote hello close quote period") == "he said \"hello\".") - #expect(format("call me open paren maybe close paren") == "call me (maybe)") - #expect(format("a well hyphen known fact") == "a well-known fact") - #expect(format("wait dash what") == "wait—what") - #expect(format("list colon eggs semicolon milk") == "list: eggs; milk") - #expect(format("really exclamation point") == "really!") - } - - @Test("line breaks take the spaces around them and start a sentence") - func lineBreaks() { - #expect(format("Dear Sam comma new paragraph thanks for writing") == "Dear Sam,\n\nThanks for writing") - #expect(format("one new line two") == "one\nTwo") - } - - @Test("phrases match case-insensitively through the service's own punctuation") - func loosePhrases() { - #expect(format("Wow, Exclamation. Point!") == "Wow!") - #expect(format("eggs Semi-colon milk") == "eggs; milk") - #expect(format("one New-line two") == "one\nTwo") - } - - @Test("punctuation inside a word stays") - func keepsWordInternalMarks() { - #expect(format("Don't, it's well-known: 3.5% of 1,000 at 10:30.") == "Don't it's well-known 3.5% of 1,000 at 10:30") - #expect(format("See cal.com, \"now\".") == "See cal.com now") - } - - @Test("dashes, quotes and brackets are stripped, even inside a token") - func stripsEverywhere() { - #expect(format("yes—no (maybe) “sure” - fine…") == "yes no maybe sure fine") - } - - @Test("only whole words are punctuation words") - func wholeWords() { - #expect(format("commas and periods") == "commas and periods") - } - - @Test("nothing but punctuation formats to nothing; a lone spoken mark is itself") - func edges() { - #expect(format("") == "") - #expect(format("...") == "") - #expect(format("Period.") == ".") - } -} - -@Suite("SpokenPunctuationStore") -struct SpokenPunctuationStoreTests { - @Test("defaults to off when unset") - func defaultsToOff() { - #expect(!SpokenPunctuationStore(defaults: freshDefaults()).isEnabled) - } - - @Test("reads back the switch the Settings toggle writes") - func readsBackTheToggledSlot() { - let defaults = freshDefaults() - let store = SpokenPunctuationStore(defaults: defaults) - defaults.set(true, forKey: SpokenPunctuationStore.defaultsKey) - #expect(store.isEnabled) - defaults.set(false, forKey: SpokenPunctuationStore.defaultsKey) - #expect(!store.isEnabled) - } -} - -@Suite("DictationSession spoken punctuation", .timeLimit(.minutes(1))) -struct DictationSessionSpokenPunctuationTests { - @Test("with the mode on, the paste is formatted; the log and history keep the service's text") - func formatsBeforePaste() async { - let spy = StringListBox() - let fixture = makeSession( - mode: .transcript("Hello comma, world period."), spokenPunctuation: true, - onTranscriptDelivered: { text, _ in spy.append(text) }) - - await fixture.session.press() - await fixture.session.release() - await fixture.session.waitForIdle() - - #expect(await fixture.injector.inserted == ["Hello, world."]) - #expect(spy.values == ["Hello, world."]) - #expect(fixture.log.transcripts.map(\.text) == ["Hello comma, world period."]) - #expect(await fixture.session.recentDictations.spokenOldestFirst == ["Hello comma, world period."]) - } - - @Test("with the mode off, the transcript is pasted untouched") - func offLeavesTextAlone() async { - let fixture = makeSession(mode: .transcript("Hello comma, world.")) - - await fixture.session.press() - await fixture.session.release() - await fixture.session.waitForIdle() - - #expect(await fixture.injector.inserted == ["Hello comma, world."]) - } - - /// Formatting first means a shortcut's saved text keeps its own punctuation, - /// which the strip would otherwise remove. - @Test("shortcuts expand after formatting, keeping their own punctuation") - func shortcutsAfterFormatting() async { - let fixture = makeSession( - mode: .transcript("Sign off, comma. Signature."), - textShortcuts: [TextShortcut(trigger: "signature", expansion: "Thanks, Sam.")], - spokenPunctuation: true) - - await fixture.session.press() - await fixture.session.release() - await fixture.session.waitForIdle() - - #expect(await fixture.injector.inserted == ["Sign off, Thanks, Sam."]) - } - - @Test("a transcript that is only the service's punctuation pastes nothing") - func punctuationOnlyIdles() async { - let fixture = makeSession(mode: .transcript("..."), spokenPunctuation: true) - - await fixture.session.press() - await fixture.session.release() - await fixture.session.waitForIdle() - - #expect(await fixture.injector.inserted.isEmpty) - } -} diff --git a/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift b/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift index f46d7015..aee4b9da 100644 --- a/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift +++ b/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift @@ -27,7 +27,6 @@ func makeSession( frontmost: CapturedFocus? = nil, keyTerms: [String] = [], textShortcuts: [TextShortcut] = [], - spokenPunctuation: Bool = false, onTranscriptDelivered: (@Sendable (String, RecentDictations) -> Void)? = nil ) -> SessionFixture { let mic = StubMicCapture() @@ -41,7 +40,6 @@ func makeSession( // developer's own Settings list can't change what a test sends. keyTermsProvider: { keyTerms }, textShortcutsProvider: { textShortcuts }, - spokenPunctuationProvider: { spokenPunctuation }, onTranscriptDelivered: onTranscriptDelivered, seams: testSeams(field: field, frontmost: frontmost, log: log)) return SessionFixture( @@ -100,8 +98,7 @@ func makeSession( ) -> DictationSession { DictationSession( mic: mic, transcriber: transcriber, injector: injector, clock: clock, - keyTermsProvider: { keyTerms }, textShortcutsProvider: { [] }, spokenPunctuationProvider: { false }, - seams: seams) + keyTermsProvider: { keyTerms }, textShortcutsProvider: { [] }, seams: seams) } extension DictationSession.Seams { From 609242e52baba6bc02e70613a93b0b7b54d4f9bf Mon Sep 17 00:00:00 2001 From: Alex Kroman Date: Tue, 29 Sep 2026 09:29:31 -0700 Subject: [PATCH 2/6] Add fn as a trigger key; rebuild the ready screen and Settings on grouped Forms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TriggerKey gains `fn` (keycode 63, secondary-fn device bit). GlobeKeyOverride sets "Press 🌐 key to" (com.apple.HIToolbox AppleFnUsageType) to Do Nothing while fn is bound and restores the replaced value on rebind, since the listen-only tap can't swallow the press. - ReadyView moves to a grouped Form: a Shortcut row with a keycap that lights while the key is down, a Style pop-up (⌘1–⌘5, "Edit Styles…"), and a Recent section; drops the hand-drawn card colors. - Settings splits into General / Styles / Vocabulary / Advanced; the Developer section now lives under Advanced. - TriggerActivation gains startVerb/finishHint for the UI copy. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 39 +- App/Blurt/Blurt.xcodeproj/project.pbxproj | 4 + App/Blurt/Blurt/AppCoordinator.swift | 9 + App/Blurt/Blurt/AppDelegate.swift | 7 +- .../CardBorder.colorset/Contents.json | 38 -- .../CardFill.colorset/Contents.json | 38 -- App/Blurt/Blurt/Branding/BlurtBrand.swift | 19 - App/Blurt/Blurt/Wizard/BlurtCommands.swift | 19 +- App/Blurt/Blurt/Wizard/DeveloperSection.swift | 128 ++++-- App/Blurt/Blurt/Wizard/KeyCap.swift | 50 ++ App/Blurt/Blurt/Wizard/MainWindowRoot.swift | 30 +- App/Blurt/Blurt/Wizard/ReadyView.swift | 426 +++++++----------- .../Wizard/RecentDictationsSection.swift | 184 ++++---- App/Blurt/Blurt/Wizard/SettingRow.swift | 73 ++- .../Blurt/Wizard/SettingsWindowRoot.swift | 118 +++-- .../Blurt/Wizard/Steps/APIKeyStepView.swift | 9 +- .../Blurt/Wizard/Steps/HotkeyStepView.swift | 5 + App/Blurt/Blurt/Wizard/StyleRow.swift | 192 ++------ .../Blurt/Wizard/TextShortcutsSection.swift | 125 +++-- App/Blurt/BlurtUITests/ReadyViewUITests.swift | 41 +- .../BlurtUITests/TextShortcutsUITests.swift | 46 +- App/Blurt/Shared/UITestIdentifiers.swift | 12 +- README.md | 2 +- .../Config/DeveloperModeStore.swift | 2 +- .../BlurtEngine/Hotkey/GlobeKeyOverride.swift | 60 +++ .../Hotkey/TriggerActivation.swift | 20 + Sources/BlurtEngine/Hotkey/TriggerKey.swift | 10 +- .../Pipeline/DictationLog+Errors.swift | 2 +- .../BlurtEngine/Pipeline/DictationLog.swift | 4 +- Sources/BlurtEngine/README.md | 5 +- .../DictationErrorLogTests.swift | 2 +- .../BlurtEngineTests/DictationLogTests.swift | 2 +- .../GlobeKeyOverrideTests.swift | 62 +++ .../TriggerActivationTests.swift | 13 + Tests/BlurtEngineTests/TriggerKeyTests.swift | 16 +- scripts/check.sh | 2 +- scripts/dev-build.sh | 5 +- scripts/leaks.sh | 2 +- scripts/uitest.sh | 2 +- 39 files changed, 959 insertions(+), 864 deletions(-) delete mode 100644 App/Blurt/Blurt/Assets.xcassets/CardBorder.colorset/Contents.json delete mode 100644 App/Blurt/Blurt/Assets.xcassets/CardFill.colorset/Contents.json create mode 100644 App/Blurt/Blurt/Wizard/KeyCap.swift create mode 100644 Sources/BlurtEngine/Hotkey/GlobeKeyOverride.swift create mode 100644 Tests/BlurtEngineTests/GlobeKeyOverrideTests.swift diff --git a/AGENTS.md b/AGENTS.md index 44bb6ad0..28ec5012 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -763,7 +763,7 @@ The dictation trigger is a **single lone modifier key** (tap-to-toggle or hold-t in-house. Four pieces, three of them pure engine logic: - **`TriggerKey`** (`Hotkey/TriggerKey.swift`) — enum of the curated lone momentary modifiers usable - as the trigger (right ⌘, right ⌥), `rawValue` = the macOS virtual keycode, plus `label` + as the trigger (right ⌘, right ⌥, `fn`), `rawValue` = the macOS virtual keycode, plus `label` ("right ⌘") and the device-modifier masks the event source needs. Right-side modifiers are chosen because a solo press rarely collides with app shortcuts. - **`TriggerKeyStore`** — persists the chosen keycode in `UserDefaults` (`BlurtTriggerKeyCode`), @@ -799,6 +799,14 @@ and silently swallow the user's next press — a latched `modifierDown` returns **swallows nothing**: a lone modifier types nothing, and combos pass through so normal shortcuts keep working. +Because the tap can't swallow `fn`, macOS's own 🌐 action ("Press 🌐 key to": emoji, input source, +or Apple Dictation — each with its own sound or popup) would fire on every `fn` dictation. The +engine's **`GlobeKeyOverride`** handles that: while `fn` is bound, `AppCoordinator` (at `start()` +and on every rebind) sets `com.apple.HIToolbox` `AppleFnUsageType` to 0 (Do Nothing), and binding +any other key restores the value it replaced. That record lives outside `DefaultsKey` on purpose, +so a settings reset doesn't erase it — the relaunch after a reset is what restores the user's +setting. Don't fix this with an active tap that swallows `fn`: the tap is listen-only for latency. + The trigger is editable in the Shortcut section of the setup/settings UI (`HotkeyStepView`) — a `Picker` over `TriggerKey.allCases` that writes `TriggerKeyStore`, after which `AppCoordinator.dictationBindingChanged()` re-reads it into the tap. For display strings, use @@ -1024,7 +1032,7 @@ sites, so a failure path added later is logged by construction; `.noTarget` and `stopAndCancel`'s failing `mic.stop()`: a cancel must not flash red, so that fault is logged **without** a phase change rather than dropped. A failed append itself goes to `os_log` (never the entry, which would leak transcripts system-wide) — it's the one error that can't be reported through the error log, -and it must never throw onto the dictation path. The Settings window's Developer section surfaces the +and it must never throw onto the dictation path. The Settings window's Advanced pane surfaces the switch and both paths, and both `scripts/reset-install.sh` and the in-app reset (`DictationLog.removeStoredLogs()`, via `InstallReset`) remove both files — plus the directory, once it holds nothing else. @@ -1053,18 +1061,23 @@ restating them. `App/Blurt/Blurt/App.swift` declares four scenes (five in Debug): - **Main window** (`MainWindowRoot`) — the setup wizard until the app is fully configured, then the - "ready" screen (`ReadyView`: the shortcut readout, swapped for a "Listening…" state while audio - is captured — driven by the same phase stream the pill renders, with Esc cancelling while this - window is key; the labeled Output Style row — one button per style, Default plus up to 4 - profiles with the active one drawn prominent, always visible, selectable by ⌘1–⌘5, locked - during capture, plus a "+" that opens Settings on its Advanced pane while under the profile cap; - the Recent list, each row trailing a chip naming the style it was made with and the relative - time, swapped for a Copy affordance on hover; and a Settings button at the - foot). Standard titlebar, always presented at launch. + "ready" screen (`ReadyView`: the wordmark over a grouped `Form` — the same surface as the Settings + panes, no hand-drawn cards. A Shortcut row trails the bound key as a keycap that fills with the + accent while the key is down, beside the configured activation ("Tap or hold", "Tap" or "Hold") + swapped for "Listening…"/"Transcribing…" on the same phase stream the pill renders, with Esc + cancelling while this window is key; a Style row's + pop-up — Default plus up to 4 profiles and an "Edit Styles…" item that opens Settings on its Styles + pane, selectable by ⌘1–⌘5, locked during capture — with a footer only while enhanced transcripts + are off, saying styles don't apply; then a Recent section, one form row per dictation trailing the + style it was made with and the relative time as plain secondary text, swapped for a Copy + affordance on hover, and when empty a first-dictation prompt. A bottom bar + holds "Powered by AssemblyAI · Report a bug" and a plain Settings button; the LinkedIn share is in + the Help menu). Standard titlebar, always presented at launch. - **Settings** (`SettingsWindowRoot`) — a `TabView` reached via ⌘, / the menu bar, - never at launch. General holds the everyday setup (key, shortcut, cue, key terms); Advanced holds - the enhanced-transcripts switch, the style profiles, the update check, the developer-mode toggle, - and the **reset** — one destructive button running the engine's `InstallReset` (the same sweep as + never at launch. General holds the everyday setup (key, shortcut, mic, cue); Styles leads with the + enhanced-transcripts switch (styles only shape that rewrite) above the style profiles; Vocabulary + holds the key terms and the text-shortcut table (+ / −, double-click to edit); Advanced holds + the update check, the developer-mode toggle, and the **reset** — one destructive button running the engine's `InstallReset` (the same sweep as `scripts/reset-install.sh`: settings, Keychain key, TCC grants, dictation logs), which confirms first and then **restarts the app** (a detached `sh -c 'sleep 1; open -n '`, since whatever reopens Blurt has to outlive it). The restart is load-bearing, not a courtesy: macOS prompts for a diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 0a567fed..29113b63 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -246,6 +246,7 @@ 8FE4B9B139BB9557FBC5589B /* OverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A27D7F43295DE2A81EFFDDBC /* OverlayWindowController.swift */; }; 91C1257866F6E2D4EE65BD09 /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B2312DE4BC0F4F070830AD7 /* WindowAccessor.swift */; }; 91D775ACB2351468D47731FF /* rom1a-12-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = E2BCA8E305B596F960CEE1AE /* rom1a-12-start.m4a */; }; + 92041B42C67CE21AF82B6863 /* KeyCap.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48C4CFC6271703797187A269 /* KeyCap.swift */; }; 9266EA4F50B9D46E9181FBAF /* rom1a-5-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 2E64BCDB280FD0A27D965AA9 /* rom1a-5-start.m4a */; }; 92DFEC2C8E639E4658C7E8C0 /* rom1a-10-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 45D65D0519A0D2FC47EF7DAD /* rom1a-10-start.m4a */; }; 936E20D5FF993C8FCF0E5575 /* juno-44-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 6B85C5A7CB0E82C4777D2430 /* juno-44-stop.m4a */; }; @@ -602,6 +603,7 @@ 4830CF6168A7511FE3720746 /* juno-13-stop.m4a */ = {isa = PBXFileReference; path = "juno-13-stop.m4a"; sourceTree = ""; }; 486F5FE65FF8C13A35C28271 /* juno-116-start.m4a */ = {isa = PBXFileReference; path = "juno-116-start.m4a"; sourceTree = ""; }; 4873421ACF290B88FA257D6A /* rom1a-7-start.m4a */ = {isa = PBXFileReference; path = "rom1a-7-start.m4a"; sourceTree = ""; }; + 48C4CFC6271703797187A269 /* KeyCap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCap.swift; sourceTree = ""; }; 49A71146BDFEA07D74A6A67C /* juno-32-stop.m4a */ = {isa = PBXFileReference; path = "juno-32-stop.m4a"; sourceTree = ""; }; 49CD6A6815EDDB16BBBCCCB2 /* rom1b-3-start.m4a */ = {isa = PBXFileReference; path = "rom1b-3-start.m4a"; sourceTree = ""; }; 49F7AD1193F18CCDF17B9EBA /* juno-6-stop.m4a */ = {isa = PBXFileReference; path = "juno-6-stop.m4a"; sourceTree = ""; }; @@ -1380,6 +1382,7 @@ D643CB1D491E00A2182907F8 /* BlurtCommands.swift */, 8999B37D9418B7A6923BD358 /* BoundTriggerKey.swift */, C7C95B4C80443E2A6EFE05E7 /* DeveloperSection.swift */, + 48C4CFC6271703797187A269 /* KeyCap.swift */, 01F10B3261EA8F8616B8595A /* MainWindowRoot.swift */, 383E03AB60BA21AAD92655D5 /* ReadyView.swift */, A0EC4E3D73862F8E4DB6F47D /* RecentDictationsSection.swift */, @@ -2027,6 +2030,7 @@ 1C40B5DB92262E05808ECCEA /* DictationKeyTap.swift in Sources */, B5A7CEA6517E5A1E4D77623F /* FloatingPanel.swift in Sources */, F42E6D4E62345772C5A49A08 /* HotkeyStepView.swift in Sources */, + 92041B42C67CE21AF82B6863 /* KeyCap.swift in Sources */, 9B7CB19D131F1374FCA0A986 /* KeyTermsStepView.swift in Sources */, E8AE03AA4DCD807D2BB8D660 /* LiquidGlass.swift in Sources */, 7F838018EA0ACE7170FC32C4 /* MainWindowRoot.swift in Sources */, diff --git a/App/Blurt/Blurt/AppCoordinator.swift b/App/Blurt/Blurt/AppCoordinator.swift index b9abd168..d410c085 100644 --- a/App/Blurt/Blurt/AppCoordinator.swift +++ b/App/Blurt/Blurt/AppCoordinator.swift @@ -101,6 +101,7 @@ final class AppCoordinator { } func start() { + syncGlobeKeyOverride() // Absorb the one-off cost of this process's first touch of the capture // stack, so the first dictation doesn't pay it on the hot path (~75 ms; see // `MicCapture.warmUp()`, which holds no recorder and opens no device). Gated @@ -224,6 +225,14 @@ final class AppCoordinator { /// to re-evaluate here. func dictationBindingChanged() { keyTap?.refreshBinding() + syncGlobeKeyOverride() + } + + /// Turns macOS's own 🌐 action off while `fn` is the trigger, and back on + /// otherwise (see `GlobeKeyOverride`). Also run from `start()`, so the relaunch + /// after a settings reset restores the user's setting. + private func syncGlobeKeyOverride() { + GlobeKeyOverride().sync(boundKey: TriggerKeyStore().triggerKey) } // MARK: - Dictation render diff --git a/App/Blurt/Blurt/AppDelegate.swift b/App/Blurt/Blurt/AppDelegate.swift index 088ebc75..942f4363 100644 --- a/App/Blurt/Blurt/AppDelegate.swift +++ b/App/Blurt/Blurt/AppDelegate.swift @@ -28,13 +28,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @ObservationIgnored lazy var updateCheckModel = UpdateCheckModel() /// One-shot deep-link into Settings, consumed by `SettingsWindowRoot`: while - /// true, the Settings window switches to (or opens on) the Advanced pane, - /// where the Styles section lives. Set by the main window's "+" (add style) - /// button just before it calls `openSettings`; observed (not just read on + /// true, the Settings window switches to (or opens on) the Styles pane. + /// Set by the main window's "Edit Styles…" item just before it calls `openSettings`; observed (not just read on /// appear) so an already-open Settings window switches too, and reset by the /// consumer so every other route into Settings (⌘,, the Settings buttons, /// the menu-bar item) still opens on General as before. - var settingsOpensOnAdvanced = false + var settingsOpensOnStyles = false /// Opens a window scene by id. The `openWindow` action lives in SwiftUI, so /// `MainWindowRoot` captures it here (in its launch-time `onAppear`) to give diff --git a/App/Blurt/Blurt/Assets.xcassets/CardBorder.colorset/Contents.json b/App/Blurt/Blurt/Assets.xcassets/CardBorder.colorset/Contents.json deleted file mode 100644 index 0c5b7df4..00000000 --- a/App/Blurt/Blurt/Assets.xcassets/CardBorder.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xDB", - "green" : "0xDB", - "red" : "0xDE" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x2F", - "green" : "0x36", - "red" : "0x3A" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/App/Blurt/Blurt/Assets.xcassets/CardFill.colorset/Contents.json b/App/Blurt/Blurt/Assets.xcassets/CardFill.colorset/Contents.json deleted file mode 100644 index 8c14171d..00000000 --- a/App/Blurt/Blurt/Assets.xcassets/CardFill.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0xE8", - "green" : "0xE8", - "red" : "0xEB" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0x1E", - "green" : "0x23", - "red" : "0x26" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/App/Blurt/Blurt/Branding/BlurtBrand.swift b/App/Blurt/Blurt/Branding/BlurtBrand.swift index 2137544e..a47292ba 100644 --- a/App/Blurt/Blurt/Branding/BlurtBrand.swift +++ b/App/Blurt/Blurt/Branding/BlurtBrand.swift @@ -48,25 +48,6 @@ enum BlurtBrand { /// decision (the row glyphs, the granted checkmark) and reads as one here. nonisolated static let accent = Color.accentColor - /// The warm grey the design fills its cards with (`#EBE8E8`), and the - /// hairline it draws round them (`#DEDBDB`). - /// - /// Scoped deliberately to the containers *we* draw — the Style row's card and - /// the Recent list. Text stays on `.primary`/`.secondary`, and the grouped - /// `Form` rows stay on system materials: those are the places where the - /// system's own colors earn their keep (Increase Contrast, vibrancy, and - /// agreeing with system-drawn labels in the same window), and where the - /// design's neutrals differ from them least anyway. - /// - /// The dark values aren't in the comps, which are light-only — they're - /// derived from the dark surfaces the design *does* specify, the overlay - /// pill and the app icon, both of which sit in the `#1D1B16` family. So the - /// card reads as a warm panel in dark rather than the cool grey a system - /// material would give. Worth replacing with real values if a dark comp - /// lands. - nonisolated static let cardFill = Color("CardFill") - nonisolated static let cardBorder = Color("CardBorder") - /// `#1D1B16` — the brand ink, and the overlay pill's body in every state. /// Fixed rather than appearance-adaptive: the pill floats over whatever app /// the user is dictating into, so it can't take its cue from the app's own diff --git a/App/Blurt/Blurt/Wizard/BlurtCommands.swift b/App/Blurt/Blurt/Wizard/BlurtCommands.swift index 864e661f..d7135904 100644 --- a/App/Blurt/Blurt/Wizard/BlurtCommands.swift +++ b/App/Blurt/Blurt/Wizard/BlurtCommands.swift @@ -15,9 +15,20 @@ struct BlurtCommands: Commands { CommandGroup(after: .appInfo) { Button("Check for Updates…") { appDelegate.updateCheckModel.checkForUpdates() } } - // Blurt ships no help book, so SwiftUI's default Help menu would show a - // dead "Blurt Help" item that opens nothing. Remove it rather than leave - // a control that does nothing. - CommandGroup(replacing: .help) {} + // Blurt ships no help book, so SwiftUI's default "Blurt Help" item would + // open nothing; it's replaced with the two outbound links that do belong + // here — the issue tracker (also in the main window's footer) and the + // share intent, which moved out of that footer so its quietest line stops + // reading as an ad. No ellipses: they open a page and ask nothing more, the + // way Apple's own Help-menu links read. Each omits itself if its URL fails + // to build. + CommandGroup(replacing: .help) { + if let url = BlurtLinks.reportBug { + Link("Report a Bug", destination: url) + } + if let url = BlurtLinks.shareOnLinkedIn { + Link("Share Blurt on LinkedIn", destination: url) + } + } } } diff --git a/App/Blurt/Blurt/Wizard/DeveloperSection.swift b/App/Blurt/Blurt/Wizard/DeveloperSection.swift index 9f26ce54..f8aaeef6 100644 --- a/App/Blurt/Blurt/Wizard/DeveloperSection.swift +++ b/App/Blurt/Blurt/Wizard/DeveloperSection.swift @@ -3,39 +3,94 @@ import BlurtEngine import OSLog import SwiftUI -// The Advanced pane's two standalone sections: the developer-mode switch and -// the start-over button. Both are Settings-only (neither gates setup, so -// neither is a wizard step), and they live here rather than in -// `SettingsWindowRoot` because that file is at the repo's file-length limit. - -/// The Developer section of the Settings window: an opt-in switch for developer -/// mode. While on, every completed dictation is appended to the local JSONL log -/// and every failed one to a sibling error log (see `DictationLog` — both gates -/// read the same default this toggle writes), and the footer shows where those -/// logs live so they're easy to find. Settings-only — not a wizard step, since it -/// never gates setup. -struct DeveloperSection: View { +// The Advanced pane's two sections: the maintenance group (version, update +// check, developer mode) and the start-over button. Both are Settings-only +// (neither gates setup, so neither is a wizard step), and they live here rather +// than in `SettingsWindowRoot` because that file is at the repo's file-length +// limit. + +/// The Advanced pane's first group: the running version with its update check, +/// and the developer-mode switch. One headerless section rather than an +/// "Updates" and a "Developer" section of one row each, whose headers only +/// repeated the row beneath them. +/// +/// Developer mode is an opt-in: while on, every completed dictation is appended +/// to the local JSONL log and every failed one to a sibling error log (see +/// `DictationLog` — both gates read the same default this toggle writes), and +/// the section grows a "Show Logs in Finder" row and a footer naming where the +/// logs live. +struct MaintenanceSection: View { + let updateModel: UpdateCheckModel + @AppStorage(DeveloperModeStore.defaultsKey) private var developerMode = false var body: some View { Section { + UpdateRow(model: updateModel) Toggle(isOn: $developerMode) { - Label("Developer mode", systemImage: "hammer") + SettingLabel(title: "Developer mode", systemImage: "hammer") } .accessibilityIdentifier(UITestIdentifiers.developerToggle) - } header: { - Text("Developer") + if developerMode { + SettingRow(title: "Logs", systemImage: "doc.text.magnifyingglass") { + Button("Show in Finder", action: showLogs) + .accessibilityIdentifier(UITestIdentifiers.developerShowLogs) + } + } } footer: { - // Both home-abbreviated paths are derived in the engine next to the URLs - // the writers append to, so this label can never drift from where the logs - // actually land. No trailing period: a path ends the line, so it can be - // selected and copied without picking up punctuation — which is also why - // the first path is followed by a plain space rather than a comma. - Text( - "Logs each dictation to \(DictationLog.defaultDisplayPath) " - + "and each failure to \(DictationLog.defaultErrorDisplayPath)" - ) - .textSelection(.enabled) + if developerMode { + // Both home-abbreviated paths are derived in the engine next to the URLs + // the writers append to, so this label can never drift from where the + // logs actually land. No trailing period: a path ends the line, so it + // can be selected and copied without picking up punctuation — which is + // also why the first path is followed by a plain space, not a comma. + Text( + "Logs each dictation to \(DictationLog.defaultDisplayPath) " + + "and each failure to \(DictationLog.defaultErrorDisplayPath)" + ) + .textSelection(.enabled) + } else { + Text("Developer mode keeps a local log of each dictation and failure.") + } + } + } + + /// Opens the log directory in Finder. The directory only exists once the first + /// entry lands, and a button that silently did nothing right after the switch + /// went on would read as broken — so it's created here if need be (the reset + /// sweep removes it again once it's empty). + private func showLogs() { + let directory = DictationLog.defaultURL.deletingLastPathComponent() + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + NSWorkspace.shared.open(directory) + } +} + +/// The running version and a "Check for Updates" button that runs the check and +/// reports the result in a modal (see `UpdateCheckModel`). The same check is +/// reachable from the "Check for Updates…" app-menu command and the menu-bar +/// item; all three share the one `UpdateCheckModel` owned by `AppDelegate`, so a +/// check from any place runs through the same controller. +private struct UpdateRow: View { + let model: UpdateCheckModel + + var body: some View { + // "Blurt 0.1.31" — the label is the engine's (shared with the result + // alerts, so the two can't name the version differently). + SettingRow(title: model.versionLabel, systemImage: "arrow.triangle.2.circlepath") { + HStack(spacing: 8) { + // A user-initiated check that can stall on a slow connection needs + // visible progress, or the button reads as dead until the result alert + // lands. Show a spinner and disable the button while in flight (the + // model already ignores a second check) — the native equivalent of + // Sparkle's "Checking for updates…". + if model.isChecking { + ProgressView().controlSize(.small) + } + Button("Check for Updates") { model.checkForUpdates() } + .disabled(model.isChecking) + .accessibilityIdentifier(UITestIdentifiers.updateCheck) + } } } } @@ -83,7 +138,7 @@ struct ResetSection: View { switch self { case .confirm: "This can’t be undone. Your AssemblyAI API key, every setting, the dictation logs, and " - + "Blurt’s microphone, accessibility and input-monitoring permissions are all removed.\n\n" + + "Blurt’s microphone and accessibility permissions are all removed.\n\n" + "Blurt then restarts and takes you back through setup." case .failed(let content): content.message } @@ -95,19 +150,30 @@ struct ResetSection: View { @State private var prompt: Prompt? var body: some View { + // No header: "Reset" over a "Reset Blurt" row only said the same word + // twice, and the red button and the footer already say what this is. Section { // Ellipsis for the same reason as "Connect…" and "Add Style…": the button // opens something rather than completing the action. SettingRow(title: "Reset Blurt", systemImage: "arrow.counterclockwise") { - Button("Reset…", role: .destructive) { prompt = .confirm } - .accessibilityIdentifier(UITestIdentifiers.installReset) + // Red text, because `role: .destructive` alone draws a bordered + // button no differently from "Check for Updates" in the section + // above — and this one deletes the key, every setting and the logs. + Button(role: .destructive) { + prompt = .confirm + } label: { + Text("Reset…").foregroundStyle(.red) + } + .accessibilityIdentifier(UITestIdentifiers.installReset) } - } header: { - Text("Reset") } footer: { + // The sweep still clears Input Monitoring (`PermissionsReset.sweep`), so + // a grant left by a build from before the hotkey stopped needing it goes + // too — but no current install has one, so naming it here only told + // users about a permission they were never asked for. Text( "Deletes your AssemblyAI API key, clears every setting, removes the dictation logs, and " - + "revokes Blurt’s microphone, accessibility and input-monitoring permissions.") + + "revokes Blurt’s microphone and accessibility permissions.") } // Alert buttons are addressed by the words on them in the UI suite, like the // update alert's "OK" — an identifier here wouldn't survive AppKit's alert diff --git a/App/Blurt/Blurt/Wizard/KeyCap.swift b/App/Blurt/Blurt/Wizard/KeyCap.swift new file mode 100644 index 00000000..a71fa7a4 --- /dev/null +++ b/App/Blurt/Blurt/Wizard/KeyCap.swift @@ -0,0 +1,50 @@ +import BlurtEngine +import SwiftUI + +/// The trigger key as a small keycap chip: the shortcut row's trailing +/// control, and the empty Recent list's "tap [Right ⌘]". At rest it takes the +/// system's quaternary fill and separator — the form's own neutrals — and, +/// when `isLit`, fills with the brand accent, which is how the shortcut row +/// answers the real key going down. +struct KeyCap: View { + var label: String + var isLit = false + @Environment(\.colorScheme) private var colorScheme + + /// The legend on the lit key. White on the light-mode accent (#01762F), but + /// the dark-mode accent (#67AD82) is too pale for white to clear 4.5:1, so + /// there the brand ink goes on it instead. + private var legend: Color { + guard isLit else { return .primary } + return colorScheme == .dark ? BlurtBrand.ink : .white + } + + var body: some View { + let shape = RoundedRectangle(cornerRadius: 5, style: .continuous) + Text(label) + .foregroundStyle(legend) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(shape.fill(isLit ? AnyShapeStyle(BlurtBrand.accent) : AnyShapeStyle(.quaternary))) + .overlay(shape.strokeBorder(isLit ? Color.clear : Color(nsColor: .separatorColor), lineWidth: 1)) + .animation(.easeOut(duration: 0.1), value: isLit) + } +} + +extension TriggerKey { + /// The keycap's face, e.g. "Right ⌘": `label` with its leading capital, + /// since a key's legend is a title rather than mid-sentence prose — except + /// `fn`, which Apple keyboards print lowercase. + var keycapLabel: String { + if self == .function { return label } + return label.prefix(1).uppercased() + label.dropFirst() + } + + /// The key named in words, e.g. "Right Command": `fullName` without its + /// parenthesised symbol. VoiceOver hears it without the symbol read as + /// the name a second time. + var spokenName: String { + guard let symbol = fullName.range(of: " (") else { return fullName } + return String(fullName[.. Void - /// The style row's "+": opens Settings deep-linked to the Advanced pane, - /// where styles are edited — a separate closure from `openSettings` so the + /// The style row's "Edit Styles…": opens Settings deep-linked to the Styles + /// pane, where styles are edited — a separate closure from `openSettings` so the /// plain Settings button keeps opening on General (see `MainWindowRoot`). var editStyles: () -> Void // Observed (not read once) so changing the dictation key in the separate // Settings window re-renders this window's keycap live — see `BoundTriggerKey`. @BoundTriggerKey private var triggerKey + /// Tap or hold, tap, or hold — observed like the key, so the shortcut row's + /// words follow a change made in Settings. Raw slot, decoded through + /// `fromPersisted`, which owns the unset default. + @AppStorage(TriggerActivationStore.defaultsKey) private var activationRaw = "" + private var activation: TriggerActivation { .fromPersisted(activationRaw) } /// Observed for the same reason as the trigger key, and bound to *observe*, /// not to write: the store owns the decoding and the active-vs-Default rule /// (see `StyleProfileStore`), and `@AppStorage` is what re-renders this window /// when the settings sheet adds a style or the switcher changes the active - /// one. Hoisted here rather than into the switcher because the status block - /// needs the active style's name too. + /// one. @AppStorage(StyleProfileStore.defaultsKey) private var rawProfiles = "" @AppStorage(StyleProfileStore.activeDefaultsKey) private var rawActiveID = "" @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -35,157 +45,70 @@ struct ReadyView: View { // second reading here. let profiles = StyleProfileStore().profiles(decoding: rawProfiles) let active = StyleProfileStore.active(in: profiles, id: rawActiveID) - // The window's vertical rhythm, measured off the design rather than picked: - // `sectionGap` between the stacked sections and between the wordmark and - // the readout, `captionGap` from a caption to the card it names (captions - // sit *above* their card, so the pair reads as label-then-thing), and a - // wider `readoutGap` under the shortcut readout — the design gives that one - // line noticeably more air before the controls start, which is what keeps - // the window from reading as a single dense stack. - VStack(spacing: MainWindow.sectionGap) { - VStack(spacing: MainWindow.sectionGap) { - ReadyBrandingView() + VStack(spacing: 0) { + ReadyBrandingView() + .padding(.top, 20) - statusBlock(activeStyleName: active?.name) - } - .padding(.bottom, MainWindow.readoutGap - MainWindow.sectionGap) - - // Always present, even with no custom styles: a pop-up holding Default - // and "Edit Styles…" is the row's empty state, so the feature is - // discoverable from the main window rather than only from Settings. - VStack(alignment: .leading, spacing: MainWindow.captionGap) { - Text("How Blurt cleans up your raw transcript") - .font(.callout) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - - StyleRow(profiles: profiles, activeID: active?.id, editStyles: editStyles) - // Locked while the mic is opening or capturing: a style picked - // mid-utterance would disagree with what the request was built with. - // `.disabled` propagates to the pop-up and the hidden ⌘1–⌘5 buttons, - // whose shortcuts don't fire while disabled. - .disabled(coordinator.isCapturing) - } - - // `displayed`, not `entries`: the ring remembers 100 dictations (they are - // also request context — see `ConversationContext`) and this list is three - // rows tall. - RecentDictationsSection(entries: coordinator.recentDictations.displayed) - - Button(action: openSettings) { - Label("Settings", systemImage: "gearshape") - .labelStyle(.titleAndIcon) - .symbolRenderingMode(.hierarchical) - } - // The system Liquid Glass button — hover/press chrome, edge highlights, - // and accessibility fallbacks come from the style, not hand-rolled fills. - // Prominent, so it takes the brand-green fill the design gives it: it's - // the only button on the window's closing line. - // Falls back to `.borderedProminent` on macOS 15–25 (see glassButtonStyleCompat). - .glassButtonStyleCompat(prominent: true) - - // The window's footer: the share line over the caption-level "Powered - // by AssemblyAI" line, grouped tight (4pt, not the section gap) so the - // two caption lines read as one closing block rather than the share - // line floating as a fourth stacked item. The block is a sibling of the - // sections rather than grouped with the button — what makes it read as - // a footer instead of a caption on the Settings button is the - // *asymmetry* below: the full section gap above it, but only half the - // window's usual inset beneath, so it sits against the bottom edge - // rather than floating between the button and the chrome. - VStack(spacing: 4) { - // A quiet share line in the footer's own voice: caption-level - // secondary prose with the links carrying `BlurtBrand.accent`, for - // the same reasons the footer's `Link` below does. Both destinations - // come from failable construction, so like the footer the line simply - // omits itself if either literal ever fails to build. Each `Link` - // carries an explicit accessibility label: the visible "X" is - // meaningless on its own in VoiceOver's links rotor. - if let xURL = Self.shareOnXURL, let linkedInURL = Self.shareOnLinkedInURL { - HStack(spacing: 3) { - Text("Share Blurt on").foregroundStyle(.secondary) - Link("X", destination: xURL) - .foregroundStyle(BlurtBrand.accent) - .accessibilityLabel("Share Blurt on X") - Text("or").foregroundStyle(.secondary) - Link("LinkedIn", destination: linkedInURL) - .foregroundStyle(BlurtBrand.accent) - .accessibilityLabel("Share Blurt on LinkedIn") - } - .font(.caption) + Form { + Section { + shortcutRow + // Always present, even with no custom styles: a pop-up holding + // Default and "Edit Styles…" is the row's empty state, so the + // feature is discoverable from the main window rather than only + // from Settings. + StyleRow(profiles: profiles, activeID: active?.id, editStyles: editStyles) + // Locked while the mic is opening or capturing: a style picked + // mid-utterance would disagree with what the request was built + // with. `.disabled` propagates to the pop-up and the hidden + // ⌘1–⌘5 buttons, whose shortcuts don't fire while disabled. + .disabled(coordinator.isCapturing) + } footer: { + StylesInertNotice() } - // Split so the linked word keeps its affordance: "Powered by" is quiet - // secondary prose, while the `Link` carries colour — all-secondary made - // the whole line indistinguishable from static text. That colour is - // `BlurtBrand.accent`, not the system link blue a bare `Link` draws - // itself in: blue would be the only instance of a second hue in a window - // whose sole accent is the brand green, and it landed directly under the - // green Settings button, giving the least important element on screen - // the second-loudest colour. `.foregroundStyle` rather than `.tint` - // because `Link` styles its own label with `NSColor.linkColor` and only - // an explicit foreground overrides it. - // - // `Link` routes through the environment's `openURL` — the default - // browser — the same road the wizard's "Get a Free Key" button takes. - // Built through the failable `URL` initializer (`force_unwrapping` is - // banned repo-wide), so like the wordmark above it simply omits itself - // if the literal ever fails to parse. - if let url = Self.poweredByURL { - HStack(spacing: 3) { - Text("Powered by").foregroundStyle(.secondary) - Link("AssemblyAI", destination: url) - .foregroundStyle(BlurtBrand.accent) - // The route to the repo's issue tracker, styled like the link - // before it. The dot is decoration, so VoiceOver skips it. - if let issuesURL = Self.reportBugURL { - Text("·").foregroundStyle(.secondary).accessibilityHidden(true) - Link("Report a bug", destination: issuesURL) - .foregroundStyle(BlurtBrand.accent) - } - } - .font(.caption) - } + // `displayed`, not `entries`: the ring remembers 100 dictations (they + // are also request context — see `ConversationContext`) and this list + // is three rows tall. + RecentDictationsSection( + entries: coordinator.recentDictations.displayed, triggerKey: triggerKey, + activation: activation) } + .formStyle(.grouped) + .scrollDisabled(true) + .fixedSize(horizontal: false, vertical: true) + + bottomBar + // The grouped form insets its sections by 20, so the bar lines up + // with their edges. + .padding(.horizontal, 20) + .padding(.bottom, 16) } - .frame(maxWidth: .infinity) - // The design's margin on both comps (its cards run x 24.5 to 455.5 in a - // 480-wide window). Was 32, which made this window's cards narrower than - // the setup window's — the grouped `Form` there insets its rows to 20 and - // can't be told otherwise. Named in `MainWindow` because `StyleRow` sizes - // its pop-up against the width this leaves. - .padding(.horizontal, MainWindow.contentMargin) - // The standard titlebar supplies the top clearance now (the logo's - // transparent margin used to), so the top matches the section spacing. - // The bottom is deliberately *half* that: the last line in the stack is - // the attribution footer, and a footer only reads as one when it is closer - // to the window's edge than to the content above it. At an even 20 it sat - // almost exactly between the Settings button and the chrome, which read as - // a fourth stacked item rather than a footer. - .padding(.top, 20) - .padding(.bottom, 10) .frame(width: MainWindow.contentWidth) .fixedSize(horizontal: false, vertical: true) } - /// The window's top block: the shortcut readout at rest, swapped for the - /// listening state while audio is actually being captured. Both render into - /// one fixed-height slot (the `RecentDictationsSection` reservation trick) so - /// the Style row and Recent list below never move on the swap. The swap is - /// gated on the same phase stream the overlay pill renders - /// (`coordinator.menuBarStatus`, whose `.recording` deliberately excludes the - /// mic bring-up) — during "Connecting…" nothing is captured yet, so claiming - /// "Listening" would invite unrecoverable speech; the readout stays put and - /// the pill carries the warming-up state. - private func statusBlock(activeStyleName: String?) -> some View { - ZStack { - if coordinator.menuBarStatus == .recording { - listeningState(activeStyleName: activeStyleName) - } else { - shortcutReadout + /// The dictation shortcut as a form row: "Shortcut" leading, and trailing + /// the bound key as a keycap beside a word on what it does. The keycap fills + /// with the accent the moment the key goes down (`isCapturing`, which + /// includes the mic bring-up) — the window's proof the hotkey is registered — + /// while the words swap from the configured gesture ("Tap or hold", "Tap", + /// or "Hold" — `TriggerActivation.label`) to the live phase. + /// + /// The phase is gated on the same stream the overlay pill renders + /// (`coordinator.menuBarStatus`, whose `.recording` deliberately excludes + /// the mic bring-up) — during "Connecting…" nothing is captured yet, so + /// claiming "Listening" would invite unrecoverable speech. + private var shortcutRow: some View { + SettingRow(title: "Shortcut", systemImage: "keyboard") { + HStack(spacing: 8) { + phaseText + .foregroundStyle(.secondary) + KeyCap(label: triggerKey.keycapLabel, isLit: coordinator.isCapturing) } } - .frame(height: Self.statusBlockHeight) + .help(activation.guidance) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Shortcut: \(triggerKey.spokenName). \(phaseDescription)") // Esc cancels the in-flight dictation — same command the overlay's owner // submits (`.cancel`), scoped to this window by living on a button in it. // Present through all of capture (not just `.recording`), so Esc during @@ -199,112 +122,107 @@ struct ReadyView: View { } } - /// Both faces are two text lines on the shared tiers, so this is simply - /// enough for either with breathing room; each centers in the same slot and - /// nothing below moves on the swap. - private static let statusBlockHeight: CGFloat = 50 - - /// Where the "Powered by AssemblyAI" footer link points. - private static let poweredByURL = URL(string: "https://www.assemblyai.com/blurt") - - /// Where the footer's "Report a bug" link points: the repo's GitHub issues. - private static let reportBugURL = URL(string: "https://github.com/AssemblyAI/blurt/issues") - - /// The prefilled post the X share opens with — short, in Blurt's voice, and - /// carrying the link itself, since the tweet intent takes only text. - private static let shareText = "Loving Blurt for dictation on my Mac 🎙️ https://www.assemblyai.com/blurt" - - /// Where the share line's "X" link points: the tweet intent with `shareText` - /// prefilled. Built through `URLComponents` so the text is percent-encoded - /// by the type rather than by hand. - private static let shareOnXURL: URL? = { - var components = URLComponents(string: "https://twitter.com/intent/tweet") - components?.queryItems = [URLQueryItem(name: "text", value: shareText)] - return components?.url - }() - - /// Where the share line's "LinkedIn" link points. LinkedIn's share intent - /// takes only a URL to share, so this one carries no message. - private static let shareOnLinkedInURL: URL? = { - var components = URLComponents(string: "https://www.linkedin.com/sharing/share-offsite/") - components?.queryItems = [URLQueryItem(name: "url", value: "https://www.assemblyai.com/blurt")] - return components?.url - }() - - /// The idle readout: "Tap **Right Command (⌘)** to start and stop." over - /// "Or hold it to talk, then release." — the key spelled out and bolded - /// inline (`TriggerKey.fullName`), no keycap chip. Static on purpose: which - /// style is in effect is the Style row's job to say — its selected button - /// is always visible right below — so repeating it here would be two - /// readouts to keep in agreement. Both lines take their ideal size: inside - /// the fixed-height slot a squeezed line can't fall back to wrapping, so - /// without this the slightest width shortfall rendered as a truncated key - /// name ("right…"). - private var shortcutReadout: some View { - VStack(spacing: 2) { - (Text("Tap ") + Text(triggerKey.fullName).bold() + Text(" to start and stop.")) - .statusPrimaryLine() - .fixedSize() - Text("Or hold it to talk, then release.") - .statusSecondaryLine() - .fixedSize() - } - } - - /// The capture-in-progress face of the top block, mirroring the idle face's - /// shape exactly — two centered lines on the shared tiers — so the swap - /// reads as the same surface changing words: the pill's waveform cue in the - /// accent (`#01762F` on light, `#67AD82` on dark — the adaptive value, not a - /// pinned shade, since this glyph lives in a window that follows the system - /// appearance) inline with a bold "Listening…", then the way out. The style - /// clause names the active profile; with none active the sentence starts at - /// "Tap again" — "Blurting in Default." reads as if a profile by that - /// name existed. - private func listeningState(activeStyleName: String?) -> some View { - VStack(spacing: 2) { - HStack(spacing: 6) { + /// The shortcut row's trailing words: how to use the key at rest, then what + /// the dictation is doing. + @ViewBuilder + private var phaseText: some View { + switch coordinator.menuBarStatus { + case .recording: + HStack(spacing: 4) { Image(systemName: "waveform") .foregroundStyle(BlurtBrand.accent) // The pill's live-capture heartbeat (`RecordingTag`), same cadence, // stilled under Reduce Motion the same way. .pulsingOpacity(period: 1.2, minOpacity: 0.4, animated: !reduceMotion) - // Bold as inline emphasis, the same role the key name's bold plays on - // the idle face. Text("Listening…") - .bold() } - // The tier on the line, not the text, so the SF Symbol scales with the - // words it sits beside instead of carrying its own size. - .statusPrimaryLine() - .fixedSize() - Text(listeningSubtitle(activeStyleName: activeStyleName)) - .statusSecondaryLine() - // One line, tail-truncated if a long style name pushes it past the - // content width: at the slot's fixed height a wrap would clip mid - // letter, and the sentence's load-bearing halves ("Blurting in - //