diff --git a/AGENTS.md b/AGENTS.md index 44bb6ad0..3b0b7ff3 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,19 @@ 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 in a suite both builds share +(`dev.alex.blurt.globe-key`) — the setting is one per login, so a per-host record let Blurt and +Blurt Dev restore each other to Do Nothing — and outside `DefaultsKey` on purpose, so a settings +reset doesn't erase it: the relaunch after a reset is what restores the user's setting. +`scripts/reset-install.sh` restores from it directly, since it wipes the host domains. The write +only takes effect at the next log-in (macOS reads it then), so the Shortcut footer says so and links +to Keyboard Settings. A keycode 63 saved before `fn` was removed (#146) is migrated to right ⌘ once +(`TriggerKeyStore.migrateStaleFunctionBinding()`), so restoring `fn` doesn't silently rebind it. 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 +1037,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 +1066,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..bfeabdae 100644 --- a/App/Blurt/Blurt/AppCoordinator.swift +++ b/App/Blurt/Blurt/AppCoordinator.swift @@ -101,6 +101,10 @@ final class AppCoordinator { } func start() { + // Before the first read of the binding — the override below, the key tap, + // and every view's `@AppStorage` copy all come after `start()`. + TriggerKeyStore().migrateStaleFunctionBinding() + 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 +228,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 - //