Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 31 additions & 13 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <bundle>'`, since whatever
reopens Blurt has to outlive it). The restart is load-bearing, not a courtesy: macOS prompts for a
Expand Down
4 changes: 4 additions & 0 deletions App/Blurt/Blurt.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -602,6 +603,7 @@
4830CF6168A7511FE3720746 /* juno-13-stop.m4a */ = {isa = PBXFileReference; path = "juno-13-stop.m4a"; sourceTree = "<group>"; };
486F5FE65FF8C13A35C28271 /* juno-116-start.m4a */ = {isa = PBXFileReference; path = "juno-116-start.m4a"; sourceTree = "<group>"; };
4873421ACF290B88FA257D6A /* rom1a-7-start.m4a */ = {isa = PBXFileReference; path = "rom1a-7-start.m4a"; sourceTree = "<group>"; };
48C4CFC6271703797187A269 /* KeyCap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCap.swift; sourceTree = "<group>"; };
49A71146BDFEA07D74A6A67C /* juno-32-stop.m4a */ = {isa = PBXFileReference; path = "juno-32-stop.m4a"; sourceTree = "<group>"; };
49CD6A6815EDDB16BBBCCCB2 /* rom1b-3-start.m4a */ = {isa = PBXFileReference; path = "rom1b-3-start.m4a"; sourceTree = "<group>"; };
49F7AD1193F18CCDF17B9EBA /* juno-6-stop.m4a */ = {isa = PBXFileReference; path = "juno-6-stop.m4a"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
12 changes: 12 additions & 0 deletions App/Blurt/Blurt/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions App/Blurt/Blurt/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 0 additions & 38 deletions App/Blurt/Blurt/Assets.xcassets/CardBorder.colorset/Contents.json

This file was deleted.

38 changes: 0 additions & 38 deletions App/Blurt/Blurt/Assets.xcassets/CardFill.colorset/Contents.json

This file was deleted.

19 changes: 0 additions & 19 deletions App/Blurt/Blurt/Branding/BlurtBrand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 15 additions & 4 deletions App/Blurt/Blurt/Wizard/BlurtCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Loading
Loading