From 5f12e175b1719698a7a75ff3a54dab0af4bcfa4b Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 16:55:03 -0500 Subject: [PATCH 1/9] iOS: AssemblyAI-owned identifiers, and API keys in every build Gets the iPhone app ready for TestFlight. - Bundle ids move from the dev.alex placeholders to com.assemblyai.blurt.ios (app, .keyboard, .core, .tests, .probe), the App Group to group.com.assemblyai.blurt.ios, and the term-pack type to com.assemblyai.blurt.terms. The Darwin notification names, log subsystem, queue labels and scripts follow. The Mac app keeps its dev.alex ids: changing them would orphan Sparkle updates and its granted Accessibility permission. - API-key entry is no longer debug-only. Sign in with AssemblyAI doesn't exist yet, so a TestFlight build had no way to add a key. The setup card and Settings now offer "Paste your key" and "Get a free key" in every build, as the Mac app does. Co-Authored-By: Claude Opus 5.5 --- .../BlurtiOS/Sources/AudioSource.swift | 4 +-- .../BlurtiOS/Sources/BlurtiOSApp.swift | 2 +- .../BlurtiOS/Sources/KeyEntryView.swift | 6 ++-- .../BlurtiOS/Sources/SettingsView.swift | 6 ++-- App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift | 18 ++++++----- App/BlurtiOS/BlurtiOSCore/SharedState.swift | 10 +++--- App/BlurtiOS/BlurtiOSCore/TermPack.swift | 2 +- App/BlurtiOS/DESIGN.md | 2 +- App/BlurtiOS/README.md | 20 ++++++------ App/BlurtiOS/project.yml | 31 ++++++++++--------- scripts/ios-keyboard-flows.sh | 6 ++-- scripts/ios-record.sh | 2 +- scripts/ios-sim.sh | 2 +- 13 files changed, 56 insertions(+), 55 deletions(-) diff --git a/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift b/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift index cd697647..d2f01c07 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/AudioSource.swift @@ -31,10 +31,10 @@ nonisolated final class WindowedAudioSource: NSObject, ListeningSource, @uncheck private let session = AVCaptureSession() private let output = AVCaptureAudioDataOutput() /// Serial, so the converter below needs no lock. - private let deliveryQueue = DispatchQueue(label: "dev.alex.blurt.ios.capture") + private let deliveryQueue = DispatchQueue(label: "com.assemblyai.blurt.ios.capture") /// Where the session is started and stopped — both block for a while on a /// phone — so the main actor never waits on the capture stack. - private let controlQueue = DispatchQueue(label: "dev.alex.blurt.ios.capture.control") + private let controlQueue = DispatchQueue(label: "com.assemblyai.blurt.ios.capture.control") private nonisolated(unsafe) var converter: PCMConverter? var levels: AsyncStream { feed.levels } diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index e8dd7066..dc33e93d 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -18,7 +18,7 @@ struct BlurtiOSApp: App { HostIdentity.configure( HostIdentity( productName: "Blurt", - subsystem: "dev.alex.blurt.ios", + subsystem: "com.assemblyai.blurt.ios", keychainService: "blurt-ios", defaultsPrefix: "Blurt", logDirectoryName: "Blurt")) diff --git a/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift b/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift index 823c6afd..0781be08 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift @@ -3,9 +3,9 @@ import BlurtiOSCore import SwiftUI /// Paste an AssemblyAI API key, verified against the API before it is saved -/// (`APIKeySubmission` owns that rule). Debug builds only: the shipping app -/// signs in with AssemblyAI and never shows a key. It exists so the pipeline can -/// be tested on a phone before that service is in place. +/// (`APIKeySubmission` owns that rule). Every build shows it, the way the Mac app +/// works, until Sign in with AssemblyAI exists; then it becomes the advanced +/// path for contributors and self-built copies. struct KeyEntryView: View { var apiKey: APIKeyModel @Environment(\.dismiss) private var dismiss diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index d37bc0d7..8a7e95f5 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -126,10 +126,8 @@ struct SettingsView: View { private var accountSection: some View { Section { - LabeledContent("Sign in with AssemblyAI", value: "Coming soon") - #if DEBUG - Button(coordinator.apiKey.hasAPIKey ? "Replace the API key" : "Use an API key") { showsKeyEntry = true } - #endif + Button(coordinator.apiKey.hasAPIKey ? "Replace the API key" : "Add an API key") { showsKeyEntry = true } + Link("Get a free key", destination: APIKeyStore.dashboardURL) } header: { Eyebrow("Account") } diff --git a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift b/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift index 4f0f5977..9ca3adc9 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift @@ -1,15 +1,17 @@ import AVFoundation +import BlurtEngine import SwiftUI import UIKit /// The setup checklist, on top of the home screen while anything is missing: -/// three numbered steps — sign in (a key, in debug builds), the microphone, -/// the keyboard — each with its control until it is done. +/// three numbered steps — an AssemblyAI API key, the microphone, the keyboard — +/// each with its control until it is done. struct SetupCard: View { let hasKey: Bool let microphoneGranted: Bool let keyboardSeen: Bool - /// Debug builds take an API key in place of the sign-in that doesn't exist yet. + /// Every build takes an API key, as the Mac app does, until Sign in with + /// AssemblyAI exists. let enterKey: () -> Void /// Re-read the permissions after one was asked for. let refresh: () -> Void @@ -17,13 +19,13 @@ struct SetupCard: View { var body: some View { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { Eyebrow("Set up Blurt") - SetupRow(number: "01", done: hasKey, title: "Sign in with AssemblyAI") { + SetupRow(number: "01", done: hasKey, title: "Add your AssemblyAI API key") { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) { - Text("Coming soon — sign-in needs the AssemblyAI login service.") + Text("Blurt uses your own AssemblyAI account. New accounts come with free credits.") .font(BlurtType.body(DesignTokens.Typography.sizeCaption)).foregroundStyle(BlurtBrand.muted) - #if DEBUG - Button("Use an API key instead") { enterKey() }.buttonStyle(BrandButtonStyle(role: .secondary)) - #endif + Button("Paste your key") { enterKey() }.buttonStyle(BrandButtonStyle(role: .primary)) + Link("Get a free key", destination: APIKeyStore.dashboardURL) + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) } } SetupRow(number: "02", done: microphoneGranted, title: "Allow the microphone") { diff --git a/App/BlurtiOS/BlurtiOSCore/SharedState.swift b/App/BlurtiOS/BlurtiOSCore/SharedState.swift index 18bff687..f6246837 100644 --- a/App/BlurtiOS/BlurtiOSCore/SharedState.swift +++ b/App/BlurtiOS/BlurtiOSCore/SharedState.swift @@ -12,7 +12,7 @@ package nonisolated enum BlurtShared { /// The App Group both targets declare. A placeholder namespace inherited from /// the Mac app; an org-owned id replaces it before the App Store, and the app /// and the keyboard must change together. - package static let appGroup = "group.dev.alex.blurt" + package static let appGroup = "group.com.assemblyai.blurt.ios" /// The URL the keyboard opens to bring the app forward (`blurt://start`) — /// the one moment iOS insists on before the microphone may open. package static let urlScheme = "blurt" @@ -46,10 +46,10 @@ package nonisolated enum BlurtShared { /// Darwin notification names. Reverse-DNS so they can't collide with another /// app's on the same device. package nonisolated enum Signal { - package static let command = "dev.alex.blurt.ios.command" - package static let phase = "dev.alex.blurt.ios.phase" - package static let result = "dev.alex.blurt.ios.result" - package static let lexicon = "dev.alex.blurt.ios.lexicon" + package static let command = "com.assemblyai.blurt.ios.command" + package static let phase = "com.assemblyai.blurt.ios.phase" + package static let result = "com.assemblyai.blurt.ios.result" + package static let lexicon = "com.assemblyai.blurt.ios.lexicon" } } diff --git a/App/BlurtiOS/BlurtiOSCore/TermPack.swift b/App/BlurtiOS/BlurtiOSCore/TermPack.swift index aa82b731..a5633e2a 100644 --- a/App/BlurtiOS/BlurtiOSCore/TermPack.swift +++ b/App/BlurtiOS/BlurtiOSCore/TermPack.swift @@ -5,7 +5,7 @@ import UniformTypeIdentifiers extension UTType { /// A shared list of Blurt key terms: JSON, `.blurtterms`, declared in /// `project.yml` so a tap on one in Messages opens Blurt. - package nonisolated static let blurtTerms = UTType(exportedAs: "dev.alex.blurt.terms", conformingTo: .json) + package nonisolated static let blurtTerms = UTType(exportedAs: "com.assemblyai.blurt.terms", conformingTo: .json) } /// A list of key terms on its way to a friend — a group chat's names and diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index d09a961a..c179f07d 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -372,7 +372,7 @@ against the request's cap of 100 terms. Settings → Transcription → **Share key terms…** puts the list in the share sheet as a `.blurtterms` file (JSON — `name`, `from`, `terms`; a declared -document type, `dev.alex.blurt.terms`) with the words as the message text. A +document type, `com.assemblyai.blurt.terms`) with the words as the message text. A tap on the file in Messages opens Blurt with "Add N key terms?": every term ticked, untick any, Add merges the rest without duplicates. A `blurt://terms?add=a,b,c&name=…&from=…` link does the same (`TermPack`, diff --git a/App/BlurtiOS/README.md b/App/BlurtiOS/README.md index ae271b0b..361af67a 100644 --- a/App/BlurtiOS/README.md +++ b/App/BlurtiOS/README.md @@ -33,9 +33,10 @@ CI builds it for the simulator on every PR (`check.yml`'s `ios-build` job). On a Mac with only the Command Line Tools — no iOS SDK — `scripts/ios-typecheck.sh` typechecks both targets against the Mac Catalyst frameworks with the same flags, which catches the Swift 6 isolation errors before CI does. Running -on a phone needs Xcode with a team that can provision the App Group; every id in -`project.yml` (`dev.alex.blurt.ios`, `group.dev.alex.blurt`) is a placeholder -inherited from the Mac app and must become org-owned before the App Store. +on a phone needs Xcode with a team that can provision the App Group. The ids in +`project.yml` (`com.assemblyai.blurt.ios`, `com.assemblyai.blurt.ios.keyboard`, +`group.com.assemblyai.blurt.ios`) are AssemblyAI's, so they register under the AssemblyAI team; +anyone building under another team changes them locally (see Testing on a phone). ## Design @@ -128,13 +129,12 @@ shows its console. characters after the team's name). The App Group needs a paid team — a free Personal Team cannot register one, so use the org's team, not your own. 3. `cd App/BlurtiOS && BLURT_TEAM= xcodegen generate && open BlurtiOS.xcodeproj`. - Automatic signing registers `dev.alex.blurt.ios`, `dev.alex.blurt.ios.keyboard` and - `group.dev.alex.blurt` under that team on the first build; it fails if another team already + Automatic signing registers `com.assemblyai.blurt.ios`, `com.assemblyai.blurt.ios.keyboard` and + `group.com.assemblyai.blurt.ios` under that team on the first build; it fails if another team already owns them. 4. Plug the phone in (Developer Mode on: Settings → Privacy & Security), pick it as the run destination, run the `BlurtiOS` scheme. On the phone, trust the developer app (Settings → - General → VPN & Device Management). In the app: **Use an API key instead** (debug builds - only), **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** + General → VPN & Device Management). In the app: **Paste your key**, **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** (a phone may open straight on Blurt's page): turn on Blurt and **Allow Full Access**. 5. Back in the app, **Start listening**, then go to Messages, hold the globe key, pick Blurt, and tap the mic. Tap again to stop, or hold it while you talk. The words land in the field. @@ -145,9 +145,9 @@ shows its console. - **Autocorrect and the suggestion bar** on the full keyboard. -- **Sign in with AssemblyAI.** The shipping app signs the user in and gets a key - behind the scenes; that service does not exist yet. Debug builds show "Use an API - key instead" (`KeyEntryView`) so the pipeline can be tested now. +- **Sign in with AssemblyAI.** The shipping app will sign the user in and get a key + behind the scenes; that service does not exist yet. Until it does, every build + (TestFlight included) takes a pasted API key (`KeyEntryView`), as the Mac app does. - **Returning to the host app.** After "Start Blurt" the user swipes back; iOS 26.4 ended automatic switch-back for everyone. - **The full keyboard** has no autocorrect or suggestions yet. diff --git a/App/BlurtiOS/project.yml b/App/BlurtiOS/project.yml index 8f45c3f7..df4d7bc8 100644 --- a/App/BlurtiOS/project.yml +++ b/App/BlurtiOS/project.yml @@ -9,13 +9,14 @@ # # Two processes, one App Group. iOS lets no keyboard use the microphone, so the # app records and talks to AssemblyAI while the keyboard is a remote control that -# inserts the result; they share `group.dev.alex.blurt` (see Shared/). Every id -# here is a placeholder namespace inherited from the Mac app — the App Store -# submission needs org-owned bundle ids, an org App Group and a team that can -# provision them. +# inserts the result; they share `group.com.assemblyai.blurt.ios` (see +# BlurtiOSCore/SharedState.swift). Every id is AssemblyAI-owned +# (`com.assemblyai.blurt.ios…`); the team comes from $BLURT_TEAM, never this +# file. The Mac app keeps its own `dev.alex.blurt` ids: changing them would +# orphan its Sparkle updates and its granted Accessibility permission. name: BlurtiOS options: - bundleIdPrefix: dev.alex + bundleIdPrefix: com.assemblyai deploymentTarget: # Matches Package.swift's `.iOS(.v18)`: an app floor below the package's fails # to resolve the dependency at all. @@ -57,7 +58,7 @@ targets: - path: BlurtiOSCore settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios.core + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.core GENERATE_INFOPLIST_FILE: YES SWIFT_TREAT_WARNINGS_AS_ERRORS: YES SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor @@ -97,7 +98,7 @@ targets: - KeyboardViewController.swift settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon # The app-level accent, from the catalog, so system-drawn chrome and our # views agree (see the Mac's project.yml and BlurtBrand). @@ -132,7 +133,7 @@ targets: # `blurt://start` — what the keyboard opens to bring the app forward, the # one moment iOS insists on before the microphone may open. CFBundleURLTypes: - - CFBundleURLName: dev.alex.blurt.ios + - CFBundleURLName: com.assemblyai.blurt.ios CFBundleURLSchemes: [blurt] # A shared key-term list (`.blurtterms`, JSON): declared so a tap on # one in Messages offers Blurt, which opens it as an import. @@ -144,9 +145,9 @@ targets: - CFBundleTypeName: Blurt key terms CFBundleTypeRole: Viewer LSHandlerRank: Owner - LSItemContentTypes: [dev.alex.blurt.terms] + LSItemContentTypes: [com.assemblyai.blurt.terms] UTExportedTypeDeclarations: - - UTTypeIdentifier: dev.alex.blurt.terms + - UTTypeIdentifier: com.assemblyai.blurt.terms UTTypeDescription: Blurt key terms UTTypeConformsTo: [public.json] UTTypeTagSpecification: @@ -155,7 +156,7 @@ targets: entitlements: path: BlurtiOS/BlurtiOS.entitlements properties: - com.apple.security.application-groups: [group.dev.alex.blurt] + com.apple.security.application-groups: [group.com.assemblyai.blurt.ios] dependencies: - package: BlurtEngine product: BlurtEngine @@ -175,7 +176,7 @@ targets: buildPhase: resources settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios.keyboard + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.keyboard SWIFT_TREAT_WARNINGS_AS_ERRORS: YES SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor APPLICATION_EXTENSION_API_ONLY: YES @@ -196,7 +197,7 @@ targets: entitlements: path: BlurtKeyboard/BlurtKeyboard.entitlements properties: - com.apple.security.application-groups: [group.dev.alex.blurt] + com.apple.security.application-groups: [group.com.assemblyai.blurt.ios] dependencies: - package: BlurtEngine product: BlurtEngine @@ -224,7 +225,7 @@ targets: - target: BlurtiOS settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios.tests + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.tests GENERATE_INFOPLIST_FILE: YES SWIFT_TREAT_WARNINGS_AS_ERRORS: YES SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor @@ -242,7 +243,7 @@ targets: - target: BlurtiOS settings: base: - PRODUCT_BUNDLE_IDENTIFIER: dev.alex.blurt.ios.probe + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.probe TEST_TARGET_NAME: BlurtiOS GENERATE_INFOPLIST_FILE: YES SWIFT_TREAT_WARNINGS_AS_ERRORS: YES diff --git a/scripts/ios-keyboard-flows.sh b/scripts/ios-keyboard-flows.sh index 04415538..076e3410 100755 --- a/scripts/ios-keyboard-flows.sh +++ b/scripts/ios-keyboard-flows.sh @@ -74,7 +74,7 @@ ios_wait_booted "$UDID" xcodebuild build -project "$REPO_ROOT/App/BlurtiOS/BlurtiOS.xcodeproj" -scheme BlurtiOS \ -destination "platform=iOS Simulator,id=$UDID" -derivedDataPath "$DERIVED" "${IOS_SIM_SIGNING[@]}" -quiet xcrun simctl install "$UDID" "$DERIVED/Build/Products/Debug-iphonesimulator/BlurtiOS.app" -xcrun simctl privacy "$UDID" grant microphone dev.alex.blurt.ios +xcrun simctl privacy "$UDID" grant microphone com.assemblyai.blurt.ios for face in $FACES; do xcrun simctl ui "$UDID" appearance "$face" @@ -136,9 +136,9 @@ echo "ios-keyboard-flows: screenshots in $OUT" # back through the simulator's own defaults daemon (a plist edit alone is # not seen until cfprefsd reloads). if [ -n "$ALIGN" ]; then - GROUP="$(xcrun simctl get_app_container "$UDID" dev.alex.blurt.ios groups 2>/dev/null | awk -F'\t' '/group.dev.alex.blurt/ {print $2}')" + GROUP="$(xcrun simctl get_app_container "$UDID" com.assemblyai.blurt.ios groups 2>/dev/null | awk -F'\t' '/group.com.assemblyai.blurt.ios/ {print $2}')" if [ -n "$GROUP" ]; then - xcrun simctl spawn "$UDID" defaults write "$GROUP/Library/Preferences/group.dev.alex.blurt" micAlignment center + xcrun simctl spawn "$UDID" defaults write "$GROUP/Library/Preferences/group.com.assemblyai.blurt.ios" micAlignment center echo "ios-keyboard-flows: mic alignment back to the middle" fi fi diff --git a/scripts/ios-record.sh b/scripts/ios-record.sh index f9437c1c..2dc45c33 100755 --- a/scripts/ios-record.sh +++ b/scripts/ios-record.sh @@ -76,7 +76,7 @@ read -r CROP_W CROP_H CROP_X CROP_Y < <(swift scripts/design-diff.swift crop "$r PICKED="$(ios_pick_device "${BLURT_SIM_DEVICE:-iPhone 18 Pro}")" UDID="${PICKED%% *}" -BUNDLE_ID=dev.alex.blurt.ios +BUNDLE_ID=com.assemblyai.blurt.ios echo "==> recording $name (two cycles)" xcrun simctl terminate "$UDID" "$BUNDLE_ID" 2>/dev/null || true diff --git a/scripts/ios-sim.sh b/scripts/ios-sim.sh index 29c7359c..d5464146 100755 --- a/scripts/ios-sim.sh +++ b/scripts/ios-sim.sh @@ -23,7 +23,7 @@ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" source "$REPO_ROOT/scripts/ios-lib.sh" DERIVED="$IOS_DERIVED" # PRODUCT_BUNDLE_IDENTIFIER in App/BlurtiOS/project.yml. -BUNDLE_ID=dev.alex.blurt.ios +BUNDLE_ID=com.assemblyai.blurt.ios SHOT="" BUILD=1 while [ $# -gt 0 ]; do From ec4bc6bfcb6b82ed02bb3fd9fbc911e582dc9325 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 17:03:47 -0500 Subject: [PATCH 2/9] iOS: ask before sending audio to AssemblyAI App Review 5.1.2(i) requires clearly disclosing where personal data goes, third-party AI included, and getting explicit permission first. The listening window now won't open until the user agrees, in a sheet that names AssemblyAI, lists what is sent (the voice while dictating, the text before the cursor, recent dictations, key terms with contact names) and links the privacy policy. Every path to the microphone goes through startListening(), so one gate covers the button, the keyboard's blurt://start and in-app dictation. Consent is versioned (AIConsent): sending something new means a new version, and everyone is asked again. Settings' About section links the privacy policy too. The debug -BlurtStartListening flag grants consent, since scripted runs can't tap through the sheet. Co-Authored-By: Claude Opus 5.5 --- .../BlurtiOS/Sources/BlurtiOSApp.swift | 4 +- .../BlurtiOS/Sources/ConsentView.swift | 49 +++++++++++++++++++ .../Sources/DictationCoordinator.swift | 16 ++++++ App/BlurtiOS/BlurtiOS/Sources/HomeView.swift | 3 ++ .../BlurtiOS/Sources/SettingsView.swift | 1 + App/BlurtiOS/BlurtiOSCore/AIConsent.swift | 28 +++++++++++ .../BlurtiOSTests/AIConsentTests.swift | 35 +++++++++++++ App/BlurtiOS/DESIGN.md | 17 +++++-- App/BlurtiOS/README.md | 2 +- 9 files changed, 148 insertions(+), 7 deletions(-) create mode 100644 App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/AIConsent.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index dc33e93d..a24e3de0 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -71,7 +71,9 @@ struct BlurtiOSApp: App { #if DEBUG // `-BlurtStartListening` opens the mic at launch, so the listening // state can be screenshotted without a tap (see scripts/ios-sim.sh). - if CommandLine.arguments.contains("-BlurtStartListening") { await coordinator.startListening() } + // Asking for it stands in for consent, which a screenshot or probe run + // can't tap through; debug builds only. + if CommandLine.arguments.contains("-BlurtStartListening") { await coordinator.grantConsent() } #endif } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift b/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift new file mode 100644 index 00000000..bc83ca04 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift @@ -0,0 +1,49 @@ +import BlurtiOSCore +import SwiftUI + +/// "Send your voice to AssemblyAI?" — asked once, before the listening window +/// first opens (App Review 5.1.2(i)). It names the third party, says plainly +/// what goes to it, and links the privacy policy. The list is the disclosure +/// `AIConsent.version` covers: change one, change the other. +struct ConsentView: View { + let allow: () -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + Section { + Text( + "Blurt turns your voice into text with AssemblyAI, a third-party AI service. " + + "It records only while you dictate.") + } + Section { + Label("Your voice while you dictate", systemImage: "waveform") + Label("The text just before your cursor", systemImage: "text.cursor") + Label("Your recent dictations", systemImage: "clock.arrow.circlepath") + Label("Your key terms, including contact names", systemImage: "person.text.rectangle") + } header: { + Eyebrow("Sent to AssemblyAI") + } footer: { + Text("The context helps AssemblyAI spell names right and continue your sentence.") + } + Section { + Link("AssemblyAI's privacy policy", destination: AIConsent.privacyPolicyURL) + } + } + .brandForm() + .navigationTitle("Before you blurt") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Not now") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Allow") { + dismiss() + allow() + } + } + } + } + .tint(BlurtBrand.accent) + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index 738df78f..7565afad 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -33,6 +33,9 @@ final class DictationCoordinator { var termPackUnreadable = false /// The window can't open without a key: the last "Start listening" said so. var needsKey = false + /// The window can't open until the user agrees to AssemblyAI processing their + /// voice (`AIConsent`); `ConsentView` asks while this is set. + var needsConsent = false @ObservationIgnored private let session: DictationSession @ObservationIgnored private let recents: AsyncStream @@ -99,6 +102,11 @@ final class DictationCoordinator { apiKey.refreshStatus() needsKey = !apiKey.hasAPIKey guard !needsKey else { return } + // Every way to the microphone comes through here — the button, the + // keyboard's `blurt://start`, and in-app dictation, which needs the window + // open — so this one gate covers them all. + needsConsent = !AIConsent.isGranted() + guard !needsConsent else { return } guard await AVAudioApplication.requestRecordPermission() else { microphoneDenied = true return @@ -107,6 +115,14 @@ final class DictationCoordinator { await window.open() } + /// The user agreed in `ConsentView`: record it and carry on opening the + /// window they asked for. + func grantConsent() async { + AIConsent.grant() + needsConsent = false + await startListening() + } + /// Closes the window. A dictation in flight is cancelled first, upload and /// all — closing the feed alone would end the utterance and let the request /// complete, transcribing (and billing) audio nobody asked for. diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index 19b3a438..dba9d8db 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -52,6 +52,9 @@ struct HomeView: View { .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) } .sheet(isPresented: $showsSettings) { SettingsView(coordinator: coordinator) } + .sheet(isPresented: Binding(get: { coordinator.needsConsent }, set: { coordinator.needsConsent = $0 })) { + ConsentView { Task { await coordinator.grantConsent() } } + } .sheet(item: Binding(get: { coordinator.pendingTermPack }, set: { coordinator.pendingTermPack = $0 })) { ImportTermsView(pack: $0) } diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 8a7e95f5..5f5a5e6d 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -137,6 +137,7 @@ struct SettingsView: View { Section { LabeledContent("Version", value: Self.version) Link("Blurt on GitHub", destination: URL(string: "https://github.com/AssemblyAI/blurt") ?? URL(filePath: "/")) + Link("AssemblyAI's privacy policy", destination: AIConsent.privacyPolicyURL) Text("Powered by AssemblyAI").foregroundStyle(BlurtBrand.muted) } header: { Eyebrow("About") diff --git a/App/BlurtiOS/BlurtiOSCore/AIConsent.swift b/App/BlurtiOS/BlurtiOSCore/AIConsent.swift new file mode 100644 index 00000000..6e7e347b --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/AIConsent.swift @@ -0,0 +1,28 @@ +import Foundation + +/// The user's go-ahead to send their voice, and the text that gives it context, +/// to AssemblyAI. App Review 5.1.2(i) requires clearly disclosing where personal +/// data goes, third-party AI included, and getting explicit permission first, so +/// the listening window won't open until this is granted. +/// +/// Versioned: consent covers what is sent, so sending something new (another +/// kind of context) means raising `version` and updating the copy that lists it +/// (`ConsentView`), and everyone is asked again. +package nonisolated enum AIConsent { + /// The version of the disclosure in `ConsentView`: 1 is audio, the text before + /// the cursor, recent dictations, and key terms with contact names. + package static let version = 1 + package static let defaultsKey = "AIConsentVersion" + package static let privacyPolicyURL = + URL(string: "https://www.assemblyai.com/legal/privacy-policy") ?? URL(filePath: "/") + + /// Whether the user agreed to the current disclosure. Agreeing to an older one + /// isn't enough: it didn't list everything that is sent now. + package static func isGranted(in defaults: UserDefaults = .standard) -> Bool { + defaults.integer(forKey: defaultsKey) >= version + } + + package static func grant(in defaults: UserDefaults = .standard) { + defaults.set(version, forKey: defaultsKey) + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift b/App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift new file mode 100644 index 00000000..bb206d5f --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/AIConsentTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing + +@testable import BlurtiOSCore + +/// The consent gate over its own defaults suite, never the app's. +@Suite("AI consent") +struct AIConsentTests { + private func freshDefaults() throws -> UserDefaults { + let name = "AIConsentTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test("nothing is granted until the user agrees, and agreeing sticks") + func grant() throws { + let defaults = try freshDefaults() + #expect(!AIConsent.isGranted(in: defaults)) + AIConsent.grant(in: defaults) + #expect(AIConsent.isGranted(in: defaults)) + } + + @Test("agreeing to an older disclosure doesn't cover the current one") + func olderVersion() throws { + let defaults = try freshDefaults() + defaults.set(AIConsent.version - 1, forKey: AIConsent.defaultsKey) + #expect(!AIConsent.isGranted(in: defaults)) + } + + @Test("the privacy policy link is AssemblyAI's") + func privacyPolicy() { + #expect(AIConsent.privacyPolicyURL.host() == "www.assemblyai.com") + } +} diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index c179f07d..d89286a1 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -690,14 +690,21 @@ time, a copy button); `POWERED BY ASSEMBLYAI`. **Settings** (`SettingsView`): a form on the page, its sections under eyebrows — Keyboard (layout, theme, hands-free), Listening (the window), Transcription (enhanced transcripts, output styles, key terms with the -contact-name count, sharing), Account (sign-in stub; the API key in debug -builds), About. The theme picker shows each theme's two faces side by side. -Styles, the import sheet and the key entry share the form's chrome -(`brandForm()`). +contact-name count, sharing), Account (the API key, and where to get one), +About (with AssemblyAI's privacy policy). The theme picker shows each theme's +two faces side by side. Styles, the import sheet, the key entry and the consent +sheet share the form's chrome (`brandForm()`). + +**Consent** (`ConsentView`, `AIConsent`): before the listening window first +opens, a sheet names AssemblyAI, lists what is sent (the voice while +dictating, the text before the cursor, recent dictations, key terms with +contact names) and links the privacy policy; `ALLOW` records it and opens the +window. App Review 5.1.2(i). The list is what `AIConsent.version` covers: +sending anything new means a new version, and everyone is asked again. `-BlurtSettings` opens Settings as the root for a screenshot; `xcrun simctl ui booted appearance dark` before any capture for the dark appearance; -`-BlurtStartListening` opens the mic at launch. +`-BlurtStartListening` opens the mic at launch (and grants AI consent, which a scripted run can't tap through). ## Feedback and accessibility diff --git a/App/BlurtiOS/README.md b/App/BlurtiOS/README.md index 361af67a..b24bc878 100644 --- a/App/BlurtiOS/README.md +++ b/App/BlurtiOS/README.md @@ -134,7 +134,7 @@ shows its console. owns them. 4. Plug the phone in (Developer Mode on: Settings → Privacy & Security), pick it as the run destination, run the `BlurtiOS` scheme. On the phone, trust the developer app (Settings → - General → VPN & Device Management). In the app: **Paste your key**, **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** + General → VPN & Device Management). In the app: **Paste your key**, **Allow** sending your voice to AssemblyAI, **Allow** the microphone, then **Open Settings** and go to **Apps › Blurt › Keyboards** (a phone may open straight on Blurt's page): turn on Blurt and **Allow Full Access**. 5. Back in the app, **Start listening**, then go to Messages, hold the globe key, pick Blurt, and tap the mic. Tap again to stop, or hold it while you talk. The words land in the field. From f7d583451327a008b10e6a0a0c3a73d18c0eaf51 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 17:08:10 -0500 Subject: [PATCH 3/9] Move the shared brand assets to Design/brand The iPhone project pulled the app icon and the wordmark straight out of the Mac app's folder, so a Mac-side move or rename would have broken the iPhone build. Both now live in a top-level Design/brand that each project references directly; neither app's folder is a dependency of the other. The Mac project lists them explicitly (it used to get them by including its whole Blurt folder) and its project.pbxproj is regenerated. Both app bundles still carry the icon and logo. Co-Authored-By: Claude Opus 5.5 --- .cursorignore | 2 +- .markdownlintignore | 2 +- App/Blurt/Blurt.xcodeproj/project.pbxproj | 25 ++++++++++++------ App/Blurt/Blurt/Wizard/ReadyView.swift | 2 +- App/Blurt/project.yml | 5 ++++ App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift | 2 +- App/BlurtiOS/project.yml | 8 +++--- Design/README.md | 11 ++++++++ .../AppIcon.icon/Assets/blurt-wordmark.png | Bin .../brand}/AppIcon.icon/icon.json | 0 .../brand}/blurt-ready-logo.png | Bin 11 files changed, 41 insertions(+), 16 deletions(-) create mode 100644 Design/README.md rename {App/Blurt/Blurt => Design/brand}/AppIcon.icon/Assets/blurt-wordmark.png (100%) rename {App/Blurt/Blurt => Design/brand}/AppIcon.icon/icon.json (100%) rename {App/Blurt/Blurt/Branding => Design/brand}/blurt-ready-logo.png (100%) diff --git a/.cursorignore b/.cursorignore index 7d07df11..bee46ded 100644 --- a/.cursorignore +++ b/.cursorignore @@ -5,4 +5,4 @@ App/Blurt/Blurt.xcodeproj/ .github/images/ # App icon asset bundle (not Swift). -App/Blurt/Blurt/AppIcon.icon/ +Design/brand/AppIcon.icon/ diff --git a/.markdownlintignore b/.markdownlintignore index 54eb2b42..fcc44ac7 100644 --- a/.markdownlintignore +++ b/.markdownlintignore @@ -7,7 +7,7 @@ App/Blurt/Blurt.xcodeproj/ .github/images/ # App icon asset bundle (not Swift). -App/Blurt/Blurt/AppIcon.icon/ +Design/brand/AppIcon.icon/ # --- lint-only (NOT mirrored to .cursorignore — Cursor still indexes these) --- diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 7a304c15..6bbdfd00 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -24,10 +24,12 @@ 0933F6F9CD7294843A63E09C /* rom1b-15-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = B401FA111CA49F5A535366AC /* rom1b-15-stop.m4a */; }; 0946D58B6886B74ED2A5DA18 /* rom1b-13-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 429B975FA7FFA777106D0A49 /* rom1b-13-stop.m4a */; }; 0A259385C40D25FC87E377D6 /* APIKeyStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DA3A304528FD1C7FE6C2B94 /* APIKeyStepView.swift */; }; + 0A37755226EF8ABE02BA42AC /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 30117E33FF99C309A29FB903 /* AppIcon.icon */; }; 0A7FB8A90BB7E7A5110A4F4D /* BlurtEngine in Frameworks */ = {isa = PBXBuildFile; productRef = 371215E93A9C2CE60C6347BB /* BlurtEngine */; }; 0BAB613C778CDF7691FAE6E6 /* rom1b-11-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 4052A873788D31A33BE80FCB /* rom1b-11-stop.m4a */; }; 0CB2E6C02E57374806139E68 /* juno-60-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 24B8138546D9CD21F6D55F54 /* juno-60-start.m4a */; }; 0D4AE0F936EAA5E3F0CE96AD /* rom1b-5-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F2B1262C9AB83ED251421D03 /* rom1b-5-start.m4a */; }; + 0DB94677FDF38489C7CE3E25 /* blurt-ready-logo.png in Resources */ = {isa = PBXBuildFile; fileRef = 49B8C7CF9FC84D9E3B96796B /* blurt-ready-logo.png */; }; 0F06FD2F6DB2831867C8B681 /* juno-52-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = ED1535C5BDC865063C5DA3A3 /* juno-52-stop.m4a */; }; 0F1317CCFA2A3245DAA96141 /* DictationComposition.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08A7417835D8538AC4A7E2FB /* DictationComposition.swift */; }; 0F6D746DF48BB048827A358F /* rom1b-29-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 786672855B44AB80CAF4D782 /* rom1b-29-stop.m4a */; }; @@ -296,7 +298,6 @@ A98B1591750D1E45F35356F5 /* juno-75-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 92CAA331F57FCD20583AF8CB /* juno-75-start.m4a */; }; AA418A6C485538A314B95258 /* rom1b-10-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7A6872FE0B51D0EC9C4B397B /* rom1b-10-stop.m4a */; }; AA61D851350DECC5A7045594 /* rom1a-27-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = A8FCDA16949B9CE1323E24F8 /* rom1a-27-start.m4a */; }; - AAD6D2EADCA8C241332764AD /* blurt-ready-logo.png in Resources */ = {isa = PBXBuildFile; fileRef = 350F18C964DCA2DDCA296ED4 /* blurt-ready-logo.png */; }; AB1C0077FA26EEDEA3542A07 /* juno-89-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 00768AE8171902B439F0602F /* juno-89-start.m4a */; }; AC402D2E4BC71A0F8324DCFD /* juno-109-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = EB08EC40A267C71B185EC782 /* juno-109-stop.m4a */; }; AD32046355138298E7FC40D0 /* MenuBarScene.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBA9C9322D0AF3D9C529F06F /* MenuBarScene.swift */; }; @@ -428,7 +429,6 @@ F0ABE7C8E87C7B955DB89601 /* juno-34-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 3012F22300681F3DD71AD51B /* juno-34-stop.m4a */; }; F164F3AA161629499C07A5F2 /* rom1b-17-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DE43545891BB9D4ED7B55D7B /* rom1b-17-stop.m4a */; }; F22CA13B8F0DB10BE6D84696 /* rom1a-21-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 5DB9D5866E9743B12589DFF4 /* rom1a-21-stop.m4a */; }; - F245B1D54675872118567839 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 1BE0AC6DFA2DC39D6D557BA3 /* AppIcon.icon */; }; F2C37DB124D3CD134315F6AD /* rom1b-20-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 662773E86D58C62C6A41801F /* rom1b-20-start.m4a */; }; F30578AA785CAE98E944F6B7 /* TextShortcutsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A50C85936C100F9E51CC2AD /* TextShortcutsSection.swift */; }; F3D87A72BED81C8532FCB503 /* juno-3-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = A2C5021E8F72182F331DA397 /* juno-3-start.m4a */; }; @@ -504,7 +504,6 @@ 19A708C2C2C7CDF52E47A8C4 /* juno-88-start.m4a */ = {isa = PBXFileReference; path = "juno-88-start.m4a"; sourceTree = ""; }; 1A1C4446E1D3FAB9F662E61D /* rom1a-12-stop.m4a */ = {isa = PBXFileReference; path = "rom1a-12-stop.m4a"; sourceTree = ""; }; 1A3D5DAAD31F8C7F67CAD5BB /* juno-32-start.m4a */ = {isa = PBXFileReference; path = "juno-32-start.m4a"; sourceTree = ""; }; - 1BE0AC6DFA2DC39D6D557BA3 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; 1C72B7BEF6CFEC52928C6CED /* juno-100-stop.m4a */ = {isa = PBXFileReference; path = "juno-100-stop.m4a"; sourceTree = ""; }; 1CF1F098A8DC41DD8508480E /* juno-8-start.m4a */ = {isa = PBXFileReference; path = "juno-8-start.m4a"; sourceTree = ""; }; 1D5C52A673686FB041595227 /* rom1b-31-stop.m4a */ = {isa = PBXFileReference; path = "rom1b-31-stop.m4a"; sourceTree = ""; }; @@ -548,6 +547,7 @@ 2E64BCDB280FD0A27D965AA9 /* rom1a-5-start.m4a */ = {isa = PBXFileReference; path = "rom1a-5-start.m4a"; sourceTree = ""; }; 2E79CB809D285C0C27E8D43D /* OverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverlayView.swift; sourceTree = ""; }; 2FFA326076660C91492A6CE1 /* juno-8-stop.m4a */ = {isa = PBXFileReference; path = "juno-8-stop.m4a"; sourceTree = ""; }; + 30117E33FF99C309A29FB903 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = ""; }; 3012F22300681F3DD71AD51B /* juno-34-stop.m4a */ = {isa = PBXFileReference; path = "juno-34-stop.m4a"; sourceTree = ""; }; 30703F589CF213720C7E3B15 /* StyleRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StyleRow.swift; sourceTree = ""; }; 30E57A9C9B934A2120381005 /* WizardController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WizardController.swift; sourceTree = ""; }; @@ -561,7 +561,6 @@ 33AE2EBBF7FA36E1E0AEBC3A /* rom1a-16-start.m4a */ = {isa = PBXFileReference; path = "rom1a-16-start.m4a"; sourceTree = ""; }; 35048A9866E106410CDCED81 /* juno-115-stop.m4a */ = {isa = PBXFileReference; path = "juno-115-stop.m4a"; sourceTree = ""; }; 35059AC7D847666F9D7FFF07 /* juno-107-start.m4a */ = {isa = PBXFileReference; path = "juno-107-start.m4a"; sourceTree = ""; }; - 350F18C964DCA2DDCA296ED4 /* blurt-ready-logo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blurt-ready-logo.png"; sourceTree = ""; }; 35EA12CB630AC702CBAC175A /* juno-106-stop.m4a */ = {isa = PBXFileReference; path = "juno-106-stop.m4a"; sourceTree = ""; }; 3611A8FA0547B780D0804F52 /* AppCoordinator+UITesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppCoordinator+UITesting.swift"; sourceTree = ""; }; 36CF30F1384D606BE74362C8 /* juno-51-start.m4a */ = {isa = PBXFileReference; path = "juno-51-start.m4a"; sourceTree = ""; }; @@ -608,6 +607,7 @@ 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 = ""; }; + 49B8C7CF9FC84D9E3B96796B /* blurt-ready-logo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = "blurt-ready-logo.png"; 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 = ""; }; 4A89CB4834FF9ABE02C969F7 /* juno-111-stop.m4a */ = {isa = PBXFileReference; path = "juno-111-stop.m4a"; sourceTree = ""; }; @@ -936,7 +936,6 @@ 240939D503F3CE03A0697A5F /* AppCoordinator.swift */, 3611A8FA0547B780D0804F52 /* AppCoordinator+UITesting.swift */, 9542F903D789DDAA55639374 /* AppDelegate.swift */, - 1BE0AC6DFA2DC39D6D557BA3 /* AppIcon.icon */, E3DD66C653EAB2EFF2CF8DAA /* Assets.xcassets */, 77E9E7F17972483F6FBF1CCD /* Blurt.entitlements */, 7D222ED050CC93DEB330E773 /* CueSoundPlayer.swift */, @@ -961,6 +960,7 @@ children = ( 1560402FBA5911463036085F /* Blurt */, 73649A7C02EB13B17D81A412 /* BlurtUITests */, + E0FCB536C7F1A0AD06EE625D /* brand */, 4B1375E36CD42DBEFF08BC7E /* Packages */, D8F69C40E4A93A6B3433BAC0 /* Shared */, 10E2E11380C974390308C1D2 /* Products */, @@ -1406,7 +1406,6 @@ 7D6DE85EA50E7CD4CC977211 /* Branding */ = { isa = PBXGroup; children = ( - 350F18C964DCA2DDCA296ED4 /* blurt-ready-logo.png */, C19DBE4661B752927D16B261 /* BlurtBrand.swift */, ); path = Branding; @@ -1466,6 +1465,16 @@ path = Hotkey; sourceTree = ""; }; + E0FCB536C7F1A0AD06EE625D /* brand */ = { + isa = PBXGroup; + children = ( + 30117E33FF99C309A29FB903 /* AppIcon.icon */, + 49B8C7CF9FC84D9E3B96796B /* blurt-ready-logo.png */, + ); + name = brand; + path = ../../Design/brand; + sourceTree = ""; + }; E7D37943B6B2CE3C0692AA48 /* Update */ = { isa = PBXGroup; children = ( @@ -1581,9 +1590,9 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - F245B1D54675872118567839 /* AppIcon.icon in Resources */, + 0A37755226EF8ABE02BA42AC /* AppIcon.icon in Resources */, 68AE6018C91E06C05EBFB2D0 /* Assets.xcassets in Resources */, - AAD6D2EADCA8C241332764AD /* blurt-ready-logo.png in Resources */, + 0DB94677FDF38489C7CE3E25 /* blurt-ready-logo.png in Resources */, 351E74AD4AF61AC397D0E29F /* juno-0-start.m4a in Resources */, 8D81DE1C806A0EE6E219D343 /* juno-0-stop.m4a in Resources */, FACF8BEBF744A47219B402F3 /* juno-1-start.m4a in Resources */, diff --git a/App/Blurt/Blurt/Wizard/ReadyView.swift b/App/Blurt/Blurt/Wizard/ReadyView.swift index 085c60e8..444b915a 100644 --- a/App/Blurt/Blurt/Wizard/ReadyView.swift +++ b/App/Blurt/Blurt/Wizard/ReadyView.swift @@ -216,7 +216,7 @@ enum BlurtLinks { } /// The `blurt` wordmark over the form: the brand-green mark -/// (`Branding/blurt-ready-logo.png`, a 720×180 rasterization of the design's +/// (`Design/brand/blurt-ready-logo.png`, a 720×180 rasterization of the design's /// vector wordmark, so its 104×26 pt slot is fed nearly 7× the pixels it needs and /// stays crisp at any display scale). Smoothly interpolated — it's curved /// letterforms now, not the pixel-art mark it replaced, which needed diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index e6da6a58..a0045157 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -141,6 +141,11 @@ targets: # Shared with BlurtUITests: the single source of truth for UI-test # identifiers/sentinels (see Shared/UITestIdentifiers.swift). - path: Shared + # The brand assets both apps ship (Design/brand), so neither app's folder + # is a dependency of the other. + - path: ../../Design/brand/AppIcon.icon + - path: ../../Design/brand/blurt-ready-logo.png + buildPhase: resources info: path: Blurt/Info.plist properties: diff --git a/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift b/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift index 2dabcfda..bb2e5f15 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift @@ -112,7 +112,7 @@ struct BrandButtonStyle: ButtonStyle { } /// The lowercase wordmark from the Mac's ready screen, tinted with the accent -/// (`blurt-ready-logo.png`, shared from `App/Blurt/Blurt/Branding`). +/// (`blurt-ready-logo.png`, shared from `Design/brand`). struct Wordmark: View { private static let height = DesignTokens.Metrics.wordmarkHeight diff --git a/App/BlurtiOS/project.yml b/App/BlurtiOS/project.yml index df4d7bc8..8579e2fb 100644 --- a/App/BlurtiOS/project.yml +++ b/App/BlurtiOS/project.yml @@ -79,10 +79,10 @@ targets: # and the bundle's own (CA92.1), the only such API either target uses. - path: BlurtiOS/PrivacyInfo.xcprivacy buildPhase: resources - # The Mac's app icon and ready-screen wordmark, shared rather than copied: - # the Icon Composer file targets iOS too (`squares: shared`). - - path: ../Blurt/Blurt/AppIcon.icon - - path: ../Blurt/Blurt/Branding/blurt-ready-logo.png + # The app icon and wordmark both apps ship (Design/brand), shared rather + # than copied: the Icon Composer file targets iOS too (`squares: shared`). + - path: ../../Design/brand/AppIcon.icon + - path: ../../Design/brand/blurt-ready-logo.png buildPhase: resources # The UI both the app and the keyboard draw with (design tokens, type, # brand colours), compiled into each — an extension can't import the diff --git a/Design/README.md b/Design/README.md new file mode 100644 index 00000000..dca2a1b3 --- /dev/null +++ b/Design/README.md @@ -0,0 +1,11 @@ +# Design + +Brand assets shared by both apps, so neither app's folder depends on the other's. + +| Path | What it is | +| ---------------------------- | ----------------------------------------------------------------- | +| `brand/AppIcon.icon` | The app icon (Icon Composer), built for macOS and iOS | +| `brand/blurt-ready-logo.png` | The `blurt` wordmark (720×180), tinted with the accent at runtime | + +Both `App/Blurt/project.yml` and `App/BlurtiOS/project.yml` reference these paths directly. +Assets one app alone uses stay with that app (for example `App/BlurtiOS/Design/tokens.json`). diff --git a/App/Blurt/Blurt/AppIcon.icon/Assets/blurt-wordmark.png b/Design/brand/AppIcon.icon/Assets/blurt-wordmark.png similarity index 100% rename from App/Blurt/Blurt/AppIcon.icon/Assets/blurt-wordmark.png rename to Design/brand/AppIcon.icon/Assets/blurt-wordmark.png diff --git a/App/Blurt/Blurt/AppIcon.icon/icon.json b/Design/brand/AppIcon.icon/icon.json similarity index 100% rename from App/Blurt/Blurt/AppIcon.icon/icon.json rename to Design/brand/AppIcon.icon/icon.json diff --git a/App/Blurt/Blurt/Branding/blurt-ready-logo.png b/Design/brand/blurt-ready-logo.png similarity index 100% rename from App/Blurt/Blurt/Branding/blurt-ready-logo.png rename to Design/brand/blurt-ready-logo.png From 275748d8db54c8b9684a518b9d023284e910c007 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 17:20:34 -0500 Subject: [PATCH 4/9] Split the AssemblyAI API client into its own library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package gains a second library, AssemblyAI, that BlurtEngine is built on: the HTTPTransport seam and its streamed upload body, the dictation wire types and multipart framing, and APIKeyValidator. It holds no Blurt policy, so it can become a standalone Swift SDK; the engine keeps everything that is a Blurt decision (the prompts, the cleanup instruction and styles, key storage, logging, the User-Agent). A pure move: no request, header or response handling changes. The wire types un-nest from AssemblyAITranscriber and become public, the SDK gets its own logger and URL helper, and the validator takes its User-Agent as a parameter. APIKeyValidator.blurt() is where Blurt's agent joins it, and both apps now use it, so their key checks still send `Blurt/ (…)`. A new test pins that the SDK's validator sends only the agent it's given. SyncSTTLimits.pcmBytes(forSeconds:) goes: its one caller, the upload buffer, now lives in the SDK and states its own size. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 9 + App/Blurt/Blurt.xcodeproj/project.pbxproj | 11 +- App/Blurt/Blurt/APIKeyModel.swift | 3 +- App/Blurt/Blurt/UITestSupport.swift | 1 + App/Blurt/project.yml | 3 + App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift | 3 +- App/BlurtiOS/project.yml | 3 + Package.swift | 13 +- .../APIKeyValidator.swift | 16 +- .../ChunkedRequestBody.swift | 8 +- .../Dictation}/DictationMultipart.swift | 8 +- .../Dictation/DictationWireTypes.swift | 221 ++++++++++++++++++ .../HTTPTransport.swift | 0 Sources/AssemblyAI/README.md | 18 ++ Sources/AssemblyAI/Support.swift | 28 +++ .../BlurtEngine/Config/APIKeySubmission.swift | 1 + .../Config/APIKeyValidator+Blurt.swift | 12 + Sources/BlurtEngine/README.md | 2 +- .../STT/AssemblyAITranscriber.swift | 1 + .../STT/DictationUploadMetrics.swift | 1 + .../BlurtEngine/STT/DictationWireTypes.swift | 213 ----------------- Sources/BlurtEngine/STT/SyncSTTLimits.swift | 8 - .../APIKeySubmissionTests.swift | 1 + .../APIKeyValidatorTests.swift | 16 +- .../AssemblyAITranscriberMultipartTests.swift | 1 + .../ChunkedRequestBodyTests.swift | 1 + .../BlurtEngineTests/KeytermsWireTests.swift | 3 +- .../Stubs/FakeHTTPTransport.swift | 1 + .../Stubs/TranscriberTestSupport.swift | 1 + scripts/mutate.sh | 2 +- 30 files changed, 366 insertions(+), 243 deletions(-) rename Sources/{BlurtEngine/Config => AssemblyAI}/APIKeyValidator.swift (84%) rename Sources/{BlurtEngine => AssemblyAI}/ChunkedRequestBody.swift (97%) rename Sources/{BlurtEngine/STT => AssemblyAI/Dictation}/DictationMultipart.swift (91%) create mode 100644 Sources/AssemblyAI/Dictation/DictationWireTypes.swift rename Sources/{BlurtEngine => AssemblyAI}/HTTPTransport.swift (100%) create mode 100644 Sources/AssemblyAI/README.md create mode 100644 Sources/AssemblyAI/Support.swift create mode 100644 Sources/BlurtEngine/Config/APIKeyValidator+Blurt.swift delete mode 100644 Sources/BlurtEngine/STT/DictationWireTypes.swift diff --git a/AGENTS.md b/AGENTS.md index 1f036d24..b7a258f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,12 @@ Four reflexes before you touch anything: | `App/Blurt/` | AppKit/SwiftUI shell (Xcode project generated by XcodeGen) that wires the engine to an overlay window, the main window, a Settings scene, a menu bar item, and the trigger key. Its only package is the local `BlurtEngine` (declared in `App/Blurt/project.yml`). | | `App/BlurtiOS/` | The iPhone app (SwiftUI) and the keyboard it embeds, generated by XcodeGen from its own `project.yml` and built by `check.yml`'s `ios-build` job. The app owns the engine's pipeline over an always-on capture; the keyboard is a remote control that inserts the words. Their logic is `BlurtiOSCore`, a framework apart from the UI the way the engine is apart from the Mac shell. `App/BlurtiOS/README.md` is its guide. | +The package's second library, **`AssemblyAI`** (`Sources/AssemblyAI/`), is the AssemblyAI API client the +engine is built on: the `HTTPTransport` seam and its streamed upload body, the dictation wire types and +multipart framing, and `APIKeyValidator`. It holds no Blurt policy (no prompts, styles, key storage or +`User-Agent`; `APIKeyValidator.blurt()` is where Blurt's agent joins it), so it can become a standalone +Swift SDK. It is part of the same local package, so the dependency-free rule is unchanged. + The engine's dependency-free rule is a **rule**. The app carries one remote package — Sparkle, the in-place updater (see [Updates](#updates)) — allowlisted by URL in `check.sh`'s dependency guard, and the rebindable dictation trigger is home-grown (`CGEventTap` over a pure state machine), not a @@ -51,6 +57,9 @@ workflow and the _why_ behind the design; the engine's README covers the _what_ ## Repository map ```text +Sources/AssemblyAI/ the AssemblyAI API client library (README.md there): HTTPTransport + + ChunkedRequestBody, Dictation/ (wire types, multipart framing), + APIKeyValidator. No Blurt policy; BlurtEngine depends on it Sources/BlurtEngine/ the engine (dependency-free Swift package) README.md the engine's developer guide (quick start, seams, error table) Audio/ MicCapture (+meter) over CaptureSessionRecorder (the diff --git a/App/Blurt/Blurt.xcodeproj/project.pbxproj b/App/Blurt/Blurt.xcodeproj/project.pbxproj index 6bbdfd00..f6533e64 100644 --- a/App/Blurt/Blurt.xcodeproj/project.pbxproj +++ b/App/Blurt/Blurt.xcodeproj/project.pbxproj @@ -288,7 +288,7 @@ A0DEFAB5DF5E5C7A6146A559 /* juno-90-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = DA27B927EB6616D017D910CD /* juno-90-stop.m4a */; }; A13335FE97FA2E60A4F5CABC /* ReadyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 383E03AB60BA21AAD92655D5 /* ReadyView.swift */; }; A1C101543CA3972691DF5367 /* rom1a-30-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 7FC3A25550C39659E3E00A11 /* rom1a-30-start.m4a */; }; - A34A51EDE26BC24EB08A3900 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 3BE06CE59B6A662701C56CE5 /* Sparkle */; }; + A34A51EDE26BC24EB08A3900 /* AssemblyAI in Frameworks */ = {isa = PBXBuildFile; productRef = 07F3519DF8F73DA30C8E55EB /* AssemblyAI */; }; A3D012E25BD52949E2452B7B /* juno-17-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = F37D9EAC78D140EE0CD438F2 /* juno-17-start.m4a */; }; A487D7B801A44C51807E0DCB /* rom1a-28-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AC2E79FB5ACE247FF90F50E4 /* rom1a-28-stop.m4a */; }; A5D51CD762229A68757463C8 /* CueSoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D222ED050CC93DEB330E773 /* CueSoundPlayer.swift */; }; @@ -379,6 +379,7 @@ D4782F83F43693371724011D /* rom1b-23-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = EAB0871FD8CB94A4DCF837CC /* rom1b-23-start.m4a */; }; D5927A391D26CAB212D6FBB4 /* rom1b-3-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 49CD6A6815EDDB16BBBCCCB2 /* rom1b-3-start.m4a */; }; D5A84975628548A076CEB3D7 /* juno-118-stop.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 92387F60666853C0AA1BF0B5 /* juno-118-stop.m4a */; }; + D6DFF322638007525EBA02B2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 3BE06CE59B6A662701C56CE5 /* Sparkle */; }; D750C1BF16502EEC92182ECD /* PermissionsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8DE60178ABA2CC7F4EB6401 /* PermissionsStepView.swift */; }; D779D9FD904DA9DDC241DA5F /* juno-70-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = AD5F1BDEB1DB3658DDB96247 /* juno-70-start.m4a */; }; D7CCAEAF551027586E39A032 /* juno-4-start.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 63189CC345106B1D56960B1E /* juno-4-start.m4a */; }; @@ -912,7 +913,8 @@ buildActionMask = 2147483647; files = ( 0A7FB8A90BB7E7A5110A4F4D /* BlurtEngine in Frameworks */, - A34A51EDE26BC24EB08A3900 /* Sparkle in Frameworks */, + A34A51EDE26BC24EB08A3900 /* AssemblyAI in Frameworks */, + D6DFF322638007525EBA02B2 /* Sparkle in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1535,6 +1537,7 @@ name = Blurt; packageProductDependencies = ( 371215E93A9C2CE60C6347BB /* BlurtEngine */, + 07F3519DF8F73DA30C8E55EB /* AssemblyAI */, 3BE06CE59B6A662701C56CE5 /* Sparkle */, ); productName = Blurt; @@ -2481,6 +2484,10 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 07F3519DF8F73DA30C8E55EB /* AssemblyAI */ = { + isa = XCSwiftPackageProductDependency; + productName = AssemblyAI; + }; 371215E93A9C2CE60C6347BB /* BlurtEngine */ = { isa = XCSwiftPackageProductDependency; productName = BlurtEngine; diff --git a/App/Blurt/Blurt/APIKeyModel.swift b/App/Blurt/Blurt/APIKeyModel.swift index a77c7160..ad8253a1 100644 --- a/App/Blurt/Blurt/APIKeyModel.swift +++ b/App/Blurt/Blurt/APIKeyModel.swift @@ -1,3 +1,4 @@ +import AssemblyAI import BlurtEngine import Observation @@ -35,7 +36,7 @@ final class APIKeyModel { init( keyStore: any APIKeyGateway = ProductionAPIKeyStore(), validateKey: @escaping @Sendable (String) async -> APIKeyValidator.Result = { - await APIKeyValidator().validate($0) + await APIKeyValidator.blurt().validate($0) } ) { self.keyStore = keyStore diff --git a/App/Blurt/Blurt/UITestSupport.swift b/App/Blurt/Blurt/UITestSupport.swift index 22b3e4f4..6c304e2a 100644 --- a/App/Blurt/Blurt/UITestSupport.swift +++ b/App/Blurt/Blurt/UITestSupport.swift @@ -1,6 +1,7 @@ #if UITEST_HOOKS import AppKit + import AssemblyAI import BlurtEngine import Foundation import Observation diff --git a/App/Blurt/project.yml b/App/Blurt/project.yml index a0045157..a8cf6afb 100644 --- a/App/Blurt/project.yml +++ b/App/Blurt/project.yml @@ -180,6 +180,9 @@ targets: dependencies: - package: BlurtEngine product: BlurtEngine + # The AssemblyAI SDK library (the key validator's types live there). + - package: BlurtEngine + product: AssemblyAI - package: Sparkle product: Sparkle postBuildScripts: diff --git a/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift b/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift index 0cb42974..2a3c9c52 100644 --- a/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift +++ b/App/BlurtiOS/BlurtiOSCore/APIKeyModel.swift @@ -1,3 +1,4 @@ +import AssemblyAI import BlurtEngine import Observation @@ -39,7 +40,7 @@ package final class APIKeyModel { package init( keyStore: any APIKeyGateway = ProductionAPIKeyStore(), validateKey: @escaping @Sendable (String) async -> APIKeyValidator.Result = { - await APIKeyValidator().validate($0) + await APIKeyValidator.blurt().validate($0) } ) { self.keyStore = keyStore diff --git a/App/BlurtiOS/project.yml b/App/BlurtiOS/project.yml index 8579e2fb..bca32eac 100644 --- a/App/BlurtiOS/project.yml +++ b/App/BlurtiOS/project.yml @@ -67,6 +67,9 @@ targets: dependencies: - package: BlurtEngine product: BlurtEngine + # The AssemblyAI SDK library (the key validator's types live there). + - package: BlurtEngine + product: AssemblyAI BlurtiOS: type: application platform: iOS diff --git a/Package.swift b/Package.swift index 69b49e7a..cfdfff1d 100644 --- a/Package.swift +++ b/Package.swift @@ -10,11 +10,20 @@ let package = Package( // for both platforms. CI's `ios-build` job is what keeps that true. platforms: [.macOS(.v15), .iOS(.v18)], products: [ - .library(name: "BlurtEngine", targets: ["BlurtEngine"]) + .library(name: "BlurtEngine", targets: ["BlurtEngine"]), + // The AssemblyAI API client the engine is built on: transport, wire types, + // key validation. Foundation only, no Blurt policy, so it can become a + // standalone SDK. See Sources/AssemblyAI/README.md. + .library(name: "AssemblyAI", targets: ["AssemblyAI"]), ], targets: [ + .target( + name: "AssemblyAI", + exclude: ["README.md"] + ), .target( name: "BlurtEngine", + dependencies: ["AssemblyAI"], // The engine's developer guide lives next to the code it documents. SwiftPM // has no rule for a stray .md inside a target, so declare it excluded rather // than let it land in the target's unhandled-files list. @@ -22,7 +31,7 @@ let package = Package( ), .testTarget( name: "BlurtEngineTests", - dependencies: ["BlurtEngine"] + dependencies: ["BlurtEngine", "AssemblyAI"] ), ] ) diff --git a/Sources/BlurtEngine/Config/APIKeyValidator.swift b/Sources/AssemblyAI/APIKeyValidator.swift similarity index 84% rename from Sources/BlurtEngine/Config/APIKeyValidator.swift rename to Sources/AssemblyAI/APIKeyValidator.swift index 17aba1ed..87d90ab2 100644 --- a/Sources/BlurtEngine/Config/APIKeyValidator.swift +++ b/Sources/AssemblyAI/APIKeyValidator.swift @@ -24,13 +24,19 @@ public struct APIKeyValidator: Sendable { private let baseURL: URL private let transport: any HTTPTransport + private let userAgent: String? + /// - Parameter userAgent: identifies the calling app (Blurt passes + /// `Blurt/ (…)`), so a key this endpoint rejects can be traced to + /// the build that asked. Nil sends URLSession's default. public init( - baseURL: URL = URL(staticString: "https://api.assemblyai.com"), - transport: any HTTPTransport = URLSession.shared + baseURL: URL = URL(literal: "https://api.assemblyai.com"), + transport: any HTTPTransport = URLSession.shared, + userAgent: String? = nil ) { self.baseURL = baseURL self.transport = transport + self.userAgent = userAgent } public func validate(_ key: String) async -> Result { @@ -50,9 +56,9 @@ public struct APIKeyValidator: Sendable { // AssemblyAI expects the raw key in Authorization (no "Bearer" prefix), // matching AssemblyAITranscriber. request.setValue(trimmed, forHTTPHeaderField: "Authorization") - // `Blurt/`, the same agent the dictation requests carry, so a key - // this endpoint rejects can be traced to the build that asked. - request.setUserAgent() + // The caller's agent (Blurt sends the one its dictation requests carry), so + // a key this endpoint rejects can be traced to the build that asked. + if let userAgent { request.setValue(userAgent, forHTTPHeaderField: "User-Agent") } do { let (_, response) = try await transport.data(for: request) diff --git a/Sources/BlurtEngine/ChunkedRequestBody.swift b/Sources/AssemblyAI/ChunkedRequestBody.swift similarity index 97% rename from Sources/BlurtEngine/ChunkedRequestBody.swift rename to Sources/AssemblyAI/ChunkedRequestBody.swift index 410cf834..6b14aea7 100644 --- a/Sources/BlurtEngine/ChunkedRequestBody.swift +++ b/Sources/AssemblyAI/ChunkedRequestBody.swift @@ -45,7 +45,8 @@ final class ChunkedRequestBody: @unchecked Sendable { /// uplink, and it is already the low end and the tightest spread (276-286 ms). /// Nothing to win by retuning it; what it protects against is a *saturated* /// uplink, which that sweep cannot manufacture and so did not test. - private static let bufferSize = SyncSTTLimits.pcmBytes(forSeconds: 1) + /// One second of 16 kHz mono 16-bit PCM, the dictation API's audio format. + private static let bufferSize = 16_000 * 2 /// Backpressure re-check interval: starts here and doubles up to /// `maxSpaceRetry` while the pipe stays full, resetting after every write that @@ -133,7 +134,7 @@ final class ChunkedRequestBody: @unchecked Sendable { // `streamError`, so the fallback was the only value it ever produced. // Logged instead, on the rare chance a future OS does populate it. if let streamError = output.streamError { - AssemblyAITranscriber.log.error( + SDKLog.transport.error( "upload pipe write failed: \(streamError.localizedDescription, privacy: .public)") } throw ChunkedUploadError.bodyStreamClosed @@ -201,7 +202,8 @@ final class ChunkedRequestBody: @unchecked Sendable { } /// Failures specific to feeding a streamed request body. Wrapped in -/// `BlurtError.sttFailed` before reaching the UI, like `AssemblyAIError`. +/// the caller's own error before reaching a UI (Blurt wraps it in +/// `BlurtError.sttFailed`). enum ChunkedUploadError: Error, LocalizedError { /// The pipe closed before the whole body was written — `URLSession` gave up /// on the request while audio was still being produced. diff --git a/Sources/BlurtEngine/STT/DictationMultipart.swift b/Sources/AssemblyAI/Dictation/DictationMultipart.swift similarity index 91% rename from Sources/BlurtEngine/STT/DictationMultipart.swift rename to Sources/AssemblyAI/Dictation/DictationMultipart.swift index c9d0a0e6..d38507a4 100644 --- a/Sources/BlurtEngine/STT/DictationMultipart.swift +++ b/Sources/AssemblyAI/Dictation/DictationMultipart.swift @@ -18,11 +18,11 @@ import Foundation /// The order they go on the wire — config, audio header, frames, closing /// boundary — is `AssemblyAITranscriber.streamedBody`'s to explain, and it is /// the streaming route's requirement rather than a choice. -enum DictationMultipart { +public enum DictationMultipart { /// The `audio` part's framing — everything before the PCM bytes themselves, /// written once, straight after the `config` part, so the frames that follow /// are just audio. - static func audioPartHeader(boundary: String) -> Data { + public static func audioPartHeader(boundary: String) -> Data { framed( "--\(boundary)\r\n", "Content-Disposition: form-data; name=\"audio\"; filename=\"audio.pcm\"\r\n", @@ -40,7 +40,7 @@ enum DictationMultipart { /// headers, the JSON, and the CRLF terminating the part. Written before a /// single audio byte — see `streamedBody` for why, and why the service cannot /// start without it. - static func configHead(config: Data, boundary: String) -> Data { + public static func configHead(config: Data, boundary: String) -> Data { var head = framed( "--\(boundary)\r\n", "Content-Disposition: form-data; name=\"config\"\r\n", @@ -53,7 +53,7 @@ enum DictationMultipart { /// Everything after the last audio frame: the `audio` part's terminating CRLF /// and the closing boundary — all that stands between the last frame and the /// request completing, which is why it carries no work. - static func closingBoundary(boundary: String) -> Data { + public static func closingBoundary(boundary: String) -> Data { framed("\r\n", "--\(boundary)--\r\n") } } diff --git a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift new file mode 100644 index 00000000..d4875504 --- /dev/null +++ b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift @@ -0,0 +1,221 @@ +// The dictation API's JSON contract: the `config` part `AssemblyAITranscriber` +// encodes, and the success/error bodies it decodes. Split from +// `AssemblyAITranscriber.swift` to stay within the lint file-length budget — +// that file is the transport (multipart framing, timeouts, metrics), this one is +// the wire shape. Top-level and public: the +// transcriber that sends them lives in BlurtEngine, a separate module. +// +// Every field name here was swept against the live route on 2026-09-10, because +// the route validates its config **strictly**: an unrecognized key earns +// `400 invalid config part: : Extra inputs are not permitted`, exactly as a +// deliberately bogus one does. The reference says the opposite about this +// endpoint — "Over HTTP, unknown fields are forwarded to the transcription +// engine as-is" — but re-measured 2026-09-11, a config carrying +// `blurt_probe_bogus_key: 1` earns `400 invalid config part: +// blurt_probe_bogus_key: Extra inputs are not permitted`. +// +// **The docs are the source of truth for what to send, and that disagreement +// does not change what this file does**: every key below is one the reference +// documents, so nothing here depends on unknown fields being either forwarded or +// rejected. Keep it that way and the question stays academic. +// +// The corollary is the one that bites. The route *accepts* more names than the +// reference lists — `prompt`, `keyterms`, `word_boost` and `language_code` are +// all live, and the docs call the first three legacy aliases — so a 200 is not +// evidence a field is supported, only that it has not been removed yet. There +// has been a lot of renaming on this route. Treat any name absent from the docs +// as deprecated and don't send it, however well it works today. +public struct DictationConfig: Encodable, Sendable { + public let sampleRate: Int + public let channels: Int + /// The contextual prompt: the text that preceded this utterance, oldest + /// first — the user's recent dictations, then the text before the cursor. + /// Steers *transcription* (continuity, spelling, mid-sentence continuation); + /// the cleanup rewrite is `llmInstruction`'s job. A **string**, and only ever a + /// string: the route rejects an array with + /// `stt_prompt: Input should be a valid string`. Empty means no prior text, + /// and `encode(to:)` then drops the key rather than sending `""`. Assembled + /// by `STTPrompt`, which is also where the 4096-scalar cap and the reason + /// this replaced `conversation_context` live. + /// + /// `prompt` is the same field under its other name — sending both earns + /// `provide only one of stt_prompt or prompt; they are the same field` — so + /// never add one alongside this. + public let sttPrompt: String + /// Keyterms prompting: the user's key terms as a flat array of strings, + /// biasing recognition toward those exact spellings. A sibling of + /// `stt_prompt`, not an alternative — the API takes both, for + /// different jobs (prior text versus a vocabulary list) — fitted by + /// `KeytermsBoost` to its own 2048-byte cap, which is a different number + /// from the 4096 scalars on the prompt. Empty asks for no boosting, and + /// `encode(to:)` then drops the key rather than sending `[]`. + /// + /// **`keyterms_prompt`, and only ever one name for it.** The route accepts + /// `keyterms` and `word_boost` as legacy aliases, and rejects any request + /// carrying two of the three: `400 provide only one of keyterms_prompt, + /// keyterms, or word_boost` (measured, 2026-09-10). So this is a swap, never + /// an addition — a compatibility shim that sent both names would 400 every + /// dictation. + public let keytermsPrompt: [String] + /// The cleanup instruction the server-side rewrite should apply + /// (`CleanupInstruction.sendable`, style profile and all), or nil to leave + /// the wording to the service. `encode(to:)` then drops the key. + /// + /// **Nil is not "no rewrite."** The route rewrites *by default*, which the + /// reference states outright — "Omitting the field, or setting it to + /// `null`, keeps the default cleanup task" — and which was measured the + /// same way on 2026-09-10: a config carrying neither `llm_instruction` nor + /// `llm` comes back with `llm_response` set to a **default-cleaned** + /// transcript. Omission selects the service's own wording; it does not + /// decline. + /// + /// **The route documents no way to decline at all**, and the escape it + /// prescribes is the one Blurt now takes: ignore `llm_response` and use + /// `text`, which is "always the verbatim transcript". So the config carries + /// no off switch, and the *response* is where the enhanced-transcripts + /// setting chooses between the two transcripts + /// (`AssemblyAITranscriber.transcript(from:)`). + /// + /// An explicit null `llm` does suppress the rewrite — measured 2026-09-10 and + /// re-confirmed 2026-09-11 (200, with `llm_response` and `llm_error` both + /// null), and the only off switch there is — but it appears **nowhere in the + /// reference**, and no documented spelling substitutes for it: `llm: {}`, + /// `llm: {"enabled": false}` and `llm_instruction: null` all run the default + /// cleanup, while invented names (`llm_enabled`, `disable_llm`) are rejected + /// as unknown keys. + /// + /// That it works is therefore not a reason to send it. **Blurt calls this + /// API only the way the docs describe it** — an undocumented field carries + /// no compatibility promise, so a switch built on one is a switch that can + /// stop being a switch without notice, silently pasting cleaned-up text to + /// users who turned cleanup off. That is the failure this whole path already + /// shipped once, by a different route. Blurt sent the null until 2026-09-11. + /// Adding an `llm` case back would reintroduce it *and* leave the response's + /// choice unreachable — read `transcript(from:)` first. + public let llmInstruction: String? + + public init( + sampleRate: Int, channels: Int, sttPrompt: String, keytermsPrompt: [String], llmInstruction: String? + ) { + self.sampleRate = sampleRate + self.channels = channels + self.sttPrompt = sttPrompt + self.keytermsPrompt = keytermsPrompt + self.llmInstruction = llmInstruction + } + enum CodingKeys: String, CodingKey { + case sampleRate = "sample_rate" + case channels + case sttPrompt = "stt_prompt" + case keytermsPrompt = "keyterms_prompt" + case llmInstruction = "llm_instruction" + } + + /// Hand-written for the one thing synthesis can't express: an empty + /// `stt_prompt` or `keyterms_prompt` must be *absent*, not `""`/`[]`, and a + /// non-optional string or array always encodes. (`llmInstruction` would + /// omit-if-nil on its own; it is spelled out here so the whole wire shape + /// reads in one place.) A property added above and forgotten here never + /// reaches the wire — which is what the config assertions in the tests + /// catch. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(sampleRate, forKey: .sampleRate) + try container.encode(channels, forKey: .channels) + if !sttPrompt.isEmpty { + try container.encode(sttPrompt, forKey: .sttPrompt) + } + if !keytermsPrompt.isEmpty { + try container.encode(keytermsPrompt, forKey: .keytermsPrompt) + } + try container.encodeIfPresent(llmInstruction, forKey: .llmInstruction) + } +} + +public struct DictationResponse: Decodable, Sendable { + /// The verbatim transcript — always present, never altered by the LLM. What + /// gets pasted with **enhanced transcripts** off, and the fallback when it + /// is on but the rewrite is unusable. + public let text: String + /// The rewritten transcript, or nil when the rewrite failed. Every request + /// asks for one, so — unlike before — nil here means failure rather than + /// possibly a declined rewrite. + public let llmResponse: String? + /// `"timeout"` or `"error"` when the rewrite failed, which is the only + /// reason `llm_response` can be null now that every request asks for one. + public let llmError: String? + /// The request identifier, which the reference says to "include it when + /// reporting problems" — so it is logged on every round trip + /// (`logServerMetrics`). Without it a user's report of a bad dictation + /// cannot be tied to the request that produced it. + public let sessionId: String? + /// The service's own duration for the audio it received. Worth having + /// beside the client's `audioMs`, which is computed from the bytes *sent*: + /// a disagreement means the upload was truncated, which no other signal + /// distinguishes from a user who simply stopped talking. + public let audioDurationMs: Double? + /// Total server-side processing time, and the transcription portion of it. + /// The pair localizes a slow dictation that the client's `postSpeechMs` + /// only reports the total of: `request_time_ms` minus `sync_time_ms` is + /// roughly what the rewrite cost, so a regression can be attributed to the + /// network, the STT upstream, or the LLM rather than guessed at. + public let requestTimeMs: Double? + public let syncTimeMs: Double? + /// **All optional except `text`, and deliberately so**, even though the + /// reference marks `session_id` and `audio_duration_ms` required. A + /// non-optional here turns a field the service stops sending into + /// `AssemblyAIError.malformedResponse` — a failed dictation, for a + /// diagnostic nobody was waiting on. `text` is the only field whose absence + /// means there is nothing to paste. + enum CodingKeys: String, CodingKey { + case text + case llmResponse = "llm_response" + case llmError = "llm_error" + case sessionId = "session_id" + case audioDurationMs = "audio_duration_ms" + case requestTimeMs = "request_time_ms" + case syncTimeMs = "sync_time_ms" + } +} + +/// A dictation API failure body. The reference documents exactly two shapes +/// and says to read both: `{error, error_code}` for most failures (400, 401, +/// 413, 429, 502, 503, 504) and `{status, title, detail}` for the ones relayed +/// from the transcription service — an invalid API key (404, *not* 401) and an +/// unsupported audio format (415). A non-string `detail` (a FastAPI-style +/// validation array) is ignored and the caller falls back to the raw body. +/// +/// **`error` is the field the common failures carry**, and reading it is not +/// optional dressing: 400 is the config-validation status, so a wrong field +/// name lands here as +/// `{"error": "invalid config part: : Extra inputs are not permitted", +/// "error_code": "bad_request"}`. This type consulted `message` and `detail` +/// until 2026-09-11, so every one of those fell through to the raw-body arm +/// and surfaced as JSON. +/// +/// **Exactly these two keys, because they are the two the reference +/// documents.** `message` was the first key read here and is in neither +/// documented shape; it was dropped rather than kept as a harmless fallback, +/// under the same rule that took `llm: null` off the request — Blurt calls +/// this API only the way the docs describe it, on both directions of the +/// wire. A body carrying some third spelling now reaches the user through the +/// raw-body arm below, which is the honest outcome: unrecognized, not silently +/// guessed at. +public struct ErrorResponse: Decodable, Sendable { + public let message: String? + + enum CodingKeys: String, CodingKey { + case error, detail + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // `try? decode` already yields `String?` for a key that is missing, null, + // or the wrong type — `decodeIfPresent` would return `String??` here and + // need flattening back down. + func string(_ key: CodingKeys) -> String? { + try? container.decode(String.self, forKey: key) + } + message = string(.error) ?? string(.detail) + } +} diff --git a/Sources/BlurtEngine/HTTPTransport.swift b/Sources/AssemblyAI/HTTPTransport.swift similarity index 100% rename from Sources/BlurtEngine/HTTPTransport.swift rename to Sources/AssemblyAI/HTTPTransport.swift diff --git a/Sources/AssemblyAI/README.md b/Sources/AssemblyAI/README.md new file mode 100644 index 00000000..94d39105 --- /dev/null +++ b/Sources/AssemblyAI/README.md @@ -0,0 +1,18 @@ +# AssemblyAI + +The AssemblyAI API client that Blurt's engine is built on, kept free of Blurt policy so it can become +a standalone Swift SDK. Foundation only; macOS 15 and iOS 18. + +| Type | What it is | +| -------------------------------------- | ------------------------------------------------------------------------------------ | +| `HTTPTransport` | The network seam. `URLSession` conforms, including a streamed (chunked) upload body. | +| `DictationConfig`, `DictationResponse` | The dictation API's `config` part and response, with only documented keys. | +| `ErrorResponse` | The dictation API's two documented error shapes. | +| `DictationMultipart` | The upload's multipart framing: `config` first, then the PCM audio. | +| `APIKeyValidator` | A cheap authenticated check that tells a rejected key from one it couldn't verify. | + +What it deliberately doesn't hold: which prompt, cleanup instruction or key terms to send, where the +key is stored, or the `User-Agent`. Those are the app's choices and live in `BlurtEngine` +(`AssemblyAITranscriber`, `APIKeyValidator.blurt()`). + +Tests run with the engine's: `swift test` from the repo root. diff --git a/Sources/AssemblyAI/Support.swift b/Sources/AssemblyAI/Support.swift new file mode 100644 index 00000000..9c1a7b06 --- /dev/null +++ b/Sources/AssemblyAI/Support.swift @@ -0,0 +1,28 @@ +import Foundation +import os + +/// The SDK's own log, apart from any app's. Only transport faults are logged. +enum SDKLog { + static let transport = Logger(subsystem: "com.assemblyai.sdk", category: "Transport") +} + +extension URL { + /// A URL from a literal known to be valid; the SDK's own copy of BlurtEngine's + /// helper, so neither module reaches into the other. `@usableFromInline` for + /// the reason the engine's copy gives: public inits' default arguments use it. + @usableFromInline + init(literal: StaticString) { + guard let url = URL(string: "\(literal)") else { + preconditionFailure("Invalid URL literal: \(literal)") + } + self = url + } +} + +extension String { + /// The string trimmed of surrounding whitespace, or nil when nothing is left. + func trimmedNonEmpty() -> String? { + let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } +} diff --git a/Sources/BlurtEngine/Config/APIKeySubmission.swift b/Sources/BlurtEngine/Config/APIKeySubmission.swift index a5c36212..c7dbb439 100644 --- a/Sources/BlurtEngine/Config/APIKeySubmission.swift +++ b/Sources/BlurtEngine/Config/APIKeySubmission.swift @@ -1,3 +1,4 @@ +import AssemblyAI /// The validate-then-save flow behind the setup/settings screen's Save/Update /// button. Owned in the engine — rather than in the app coordinator — so its /// central invariant is unit-tested: **an unverified key never persists.** A key diff --git a/Sources/BlurtEngine/Config/APIKeyValidator+Blurt.swift b/Sources/BlurtEngine/Config/APIKeyValidator+Blurt.swift new file mode 100644 index 00000000..9586cb64 --- /dev/null +++ b/Sources/BlurtEngine/Config/APIKeyValidator+Blurt.swift @@ -0,0 +1,12 @@ +import AssemblyAI +import Foundation + +extension APIKeyValidator { + /// AssemblyAI's key check as Blurt sends it: with `UserAgent.current`, the same + /// `Blurt/ (…)` agent the dictation requests carry, so a rejected key + /// can be traced to the build that asked. The validator itself lives in the + /// AssemblyAI SDK and knows nothing of Blurt; this is where the agent joins it. + public static func blurt(transport: any HTTPTransport = URLSession.shared) -> APIKeyValidator { + APIKeyValidator(transport: transport, userAgent: UserAgent.current) + } +} diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index 674cf7e5..e4112246 100644 --- a/Sources/BlurtEngine/README.md +++ b/Sources/BlurtEngine/README.md @@ -191,7 +191,7 @@ func transcribe( func warmUp() async // optional; no-op default ``` -`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://dictation.assemblyai.com/v1/transcribe/live` per utterance, **uploaded in chunks while the recording is still happening**: called at press, it writes the whole `config` part and the `audio` part's headers immediately and then each captured frame as `frames` delivers it, so finishing that stream is what signals end-of-audio. `URLRequest.httpBodyStream` is fed through a `CFStream` bound pair (`ChunkedRequestBody`) because no `URLSession` API takes an `AsyncSequence` body; leaving `Content-Length` unset is what makes the transfer chunked. The body is a JSON `config` part (`sample_rate`, `channels`, and `llm_instruction` carrying `CleanupInstruction.text` with any custom style instructions appended — `CleanupInstruction.sendable(appending:)` — on every request, whatever the enhanced-transcripts setting says) followed by the raw S16LE PCM byte-for-byte, with the API key in `Authorization` and `Blurt/ (macOS .)` in `User-Agent` — `; dev` appended off a release build, so local installs stay out of the service-side baseline (see `UserAgent`; no model header — the service pins the STT model server-side). That `config` part is written **first**, and this route requires it — it rejects an audio-first body with a 400 (see `AssemblyAITranscriber.streamedBody` for the service's exact wording). That is where the latency comes from — the service opens its upstream STT call as soon as the config lands, so inference overlaps the recording and nothing sits between the last frame and the response. The cost is that the press-time focused-field read has to be settled _before_ the request opens, so `DictationSession.startUpload` awaits it (bounded by `contextWaitBudget`) and hands it in as a value; there is no longer a `context` channel for the body producer to wait on. The response carries the verbatim `text`, the rewritten `llm_response`, and the documented request metadata — `session_id` (quote it when reporting a problem), `audio_duration_ms`, `request_time_ms` and `sync_time_ms`, all logged by `logServerMetrics` and all decoded optionally so a dropped diagnostic can never turn a good dictation into a `malformedResponse`. `transcript(from:)` picks between the two transcripts: with enhanced transcripts on it returns the rewrite and falls back to `text` when that is null or blank (the rewrite is best-effort — `llm_error` is logged, never surfaced as a failure); with it off it returns `text`. Its initializer takes an `apiKeyProvider` closure (defaults to `APIKeyStore.current`), a `baseURL`, an `HTTPTransport` — inject a fake transport (see `Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift`) to test against canned responses — an `enhancedTranscripts` closure deciding, per request, which of the response's two transcripts to paste (nil, the default, reads `EnhancedTranscriptsStore`), and a `customStyle` closure supplying the style instructions appended to the cleanup instruction (nil, the default, reads the active profile from `StyleProfileStore`). `warmUp()` (in `DictationWarmUp.swift`) fires a GET at the route's own `/warm` endpoint — "an unauthenticated no-op" published for exactly this — to pre-pool the connection; it hit the bare host root until 2026-09-11, which pooled the same connection but asked for a page the service never documented answering. It never throws, and any failure just means the real request pays connection setup as before. The reference's two conditions for the pooled connection to still be there are structural here: same client (the injected `transport`, which is the one `transcribe` uses) and same host (`baseURL`, so a data-zone override warms the zone it will call). +`AssemblyAITranscriber` is a stateless `Sendable` struct. One `POST https://dictation.assemblyai.com/v1/transcribe/live` per utterance, **uploaded in chunks while the recording is still happening**: called at press, it writes the whole `config` part and the `audio` part's headers immediately and then each captured frame as `frames` delivers it, so finishing that stream is what signals end-of-audio. `URLRequest.httpBodyStream` is fed through a `CFStream` bound pair (`ChunkedRequestBody`, in the `AssemblyAI` library with `HTTPTransport`, the wire types and `APIKeyValidator`) because no `URLSession` API takes an `AsyncSequence` body; leaving `Content-Length` unset is what makes the transfer chunked. The body is a JSON `config` part (`sample_rate`, `channels`, and `llm_instruction` carrying `CleanupInstruction.text` with any custom style instructions appended — `CleanupInstruction.sendable(appending:)` — on every request, whatever the enhanced-transcripts setting says) followed by the raw S16LE PCM byte-for-byte, with the API key in `Authorization` and `Blurt/ (macOS .)` in `User-Agent` — `; dev` appended off a release build, so local installs stay out of the service-side baseline (see `UserAgent`; no model header — the service pins the STT model server-side). That `config` part is written **first**, and this route requires it — it rejects an audio-first body with a 400 (see `AssemblyAITranscriber.streamedBody` for the service's exact wording). That is where the latency comes from — the service opens its upstream STT call as soon as the config lands, so inference overlaps the recording and nothing sits between the last frame and the response. The cost is that the press-time focused-field read has to be settled _before_ the request opens, so `DictationSession.startUpload` awaits it (bounded by `contextWaitBudget`) and hands it in as a value; there is no longer a `context` channel for the body producer to wait on. The response carries the verbatim `text`, the rewritten `llm_response`, and the documented request metadata — `session_id` (quote it when reporting a problem), `audio_duration_ms`, `request_time_ms` and `sync_time_ms`, all logged by `logServerMetrics` and all decoded optionally so a dropped diagnostic can never turn a good dictation into a `malformedResponse`. `transcript(from:)` picks between the two transcripts: with enhanced transcripts on it returns the rewrite and falls back to `text` when that is null or blank (the rewrite is best-effort — `llm_error` is logged, never surfaced as a failure); with it off it returns `text`. Its initializer takes an `apiKeyProvider` closure (defaults to `APIKeyStore.current`), a `baseURL`, an `HTTPTransport` — inject a fake transport (see `Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift`) to test against canned responses — an `enhancedTranscripts` closure deciding, per request, which of the response's two transcripts to paste (nil, the default, reads `EnhancedTranscriptsStore`), and a `customStyle` closure supplying the style instructions appended to the cleanup instruction (nil, the default, reads the active profile from `StyleProfileStore`). `warmUp()` (in `DictationWarmUp.swift`) fires a GET at the route's own `/warm` endpoint — "an unauthenticated no-op" published for exactly this — to pre-pool the connection; it hit the bare host root until 2026-09-11, which pooled the same connection but asked for a page the service never documented answering. It never throws, and any failure just means the real request pays connection setup as before. The reference's two conditions for the pooled connection to still be there are structural here: same client (the injected `transport`, which is the one `transcribe` uses) and same host (`baseURL`, so a data-zone override warms the zone it will call). The model's limits live in `SyncSTTLimits` (16 kHz sample rate, ~0.1 s–120 s audio, and the auto-release math — the sync STT model behind the dictation service) — the single source shared by the mic, the session, and the request so recorded and declared geometry can't drift. diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index 052de6a8..ebddcf0a 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation import os diff --git a/Sources/BlurtEngine/STT/DictationUploadMetrics.swift b/Sources/BlurtEngine/STT/DictationUploadMetrics.swift index 0a3032ec..2f9c581b 100644 --- a/Sources/BlurtEngine/STT/DictationUploadMetrics.swift +++ b/Sources/BlurtEngine/STT/DictationUploadMetrics.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation import Synchronization import os diff --git a/Sources/BlurtEngine/STT/DictationWireTypes.swift b/Sources/BlurtEngine/STT/DictationWireTypes.swift deleted file mode 100644 index 8f329518..00000000 --- a/Sources/BlurtEngine/STT/DictationWireTypes.swift +++ /dev/null @@ -1,213 +0,0 @@ -// The dictation API's JSON contract: the `config` part `AssemblyAITranscriber` -// encodes, and the success/error bodies it decodes. Split from -// `AssemblyAITranscriber.swift` to stay within the lint file-length budget — -// that file is the transport (multipart framing, timeouts, metrics), this one is -// the wire shape. Nested in the transcriber, and internal rather than private, -// only because Swift's `private` is file-scoped and cannot cross the split. -// -// Every field name here was swept against the live route on 2026-09-10, because -// the route validates its config **strictly**: an unrecognized key earns -// `400 invalid config part: : Extra inputs are not permitted`, exactly as a -// deliberately bogus one does. The reference says the opposite about this -// endpoint — "Over HTTP, unknown fields are forwarded to the transcription -// engine as-is" — but re-measured 2026-09-11, a config carrying -// `blurt_probe_bogus_key: 1` earns `400 invalid config part: -// blurt_probe_bogus_key: Extra inputs are not permitted`. -// -// **The docs are the source of truth for what to send, and that disagreement -// does not change what this file does**: every key below is one the reference -// documents, so nothing here depends on unknown fields being either forwarded or -// rejected. Keep it that way and the question stays academic. -// -// The corollary is the one that bites. The route *accepts* more names than the -// reference lists — `prompt`, `keyterms`, `word_boost` and `language_code` are -// all live, and the docs call the first three legacy aliases — so a 200 is not -// evidence a field is supported, only that it has not been removed yet. There -// has been a lot of renaming on this route. Treat any name absent from the docs -// as deprecated and don't send it, however well it works today. -extension AssemblyAITranscriber { - struct DictationConfig: Encodable { - let sampleRate: Int - let channels: Int - /// The contextual prompt: the text that preceded this utterance, oldest - /// first — the user's recent dictations, then the text before the cursor. - /// Steers *transcription* (continuity, spelling, mid-sentence continuation); - /// the cleanup rewrite is `llmInstruction`'s job. A **string**, and only ever a - /// string: the route rejects an array with - /// `stt_prompt: Input should be a valid string`. Empty means no prior text, - /// and `encode(to:)` then drops the key rather than sending `""`. Assembled - /// by `STTPrompt`, which is also where the 4096-scalar cap and the reason - /// this replaced `conversation_context` live. - /// - /// `prompt` is the same field under its other name — sending both earns - /// `provide only one of stt_prompt or prompt; they are the same field` — so - /// never add one alongside this. - let sttPrompt: String - /// Keyterms prompting: the user's key terms as a flat array of strings, - /// biasing recognition toward those exact spellings. A sibling of - /// `stt_prompt`, not an alternative — the API takes both, for - /// different jobs (prior text versus a vocabulary list) — fitted by - /// `KeytermsBoost` to its own 2048-byte cap, which is a different number - /// from the 4096 scalars on the prompt. Empty asks for no boosting, and - /// `encode(to:)` then drops the key rather than sending `[]`. - /// - /// **`keyterms_prompt`, and only ever one name for it.** The route accepts - /// `keyterms` and `word_boost` as legacy aliases, and rejects any request - /// carrying two of the three: `400 provide only one of keyterms_prompt, - /// keyterms, or word_boost` (measured, 2026-09-10). So this is a swap, never - /// an addition — a compatibility shim that sent both names would 400 every - /// dictation. - let keytermsPrompt: [String] - /// The cleanup instruction the server-side rewrite should apply - /// (`CleanupInstruction.sendable`, style profile and all), or nil to leave - /// the wording to the service. `encode(to:)` then drops the key. - /// - /// **Nil is not "no rewrite."** The route rewrites *by default*, which the - /// reference states outright — "Omitting the field, or setting it to - /// `null`, keeps the default cleanup task" — and which was measured the - /// same way on 2026-09-10: a config carrying neither `llm_instruction` nor - /// `llm` comes back with `llm_response` set to a **default-cleaned** - /// transcript. Omission selects the service's own wording; it does not - /// decline. - /// - /// **The route documents no way to decline at all**, and the escape it - /// prescribes is the one Blurt now takes: ignore `llm_response` and use - /// `text`, which is "always the verbatim transcript". So the config carries - /// no off switch, and the *response* is where the enhanced-transcripts - /// setting chooses between the two transcripts - /// (`AssemblyAITranscriber.transcript(from:)`). - /// - /// An explicit null `llm` does suppress the rewrite — measured 2026-09-10 and - /// re-confirmed 2026-09-11 (200, with `llm_response` and `llm_error` both - /// null), and the only off switch there is — but it appears **nowhere in the - /// reference**, and no documented spelling substitutes for it: `llm: {}`, - /// `llm: {"enabled": false}` and `llm_instruction: null` all run the default - /// cleanup, while invented names (`llm_enabled`, `disable_llm`) are rejected - /// as unknown keys. - /// - /// That it works is therefore not a reason to send it. **Blurt calls this - /// API only the way the docs describe it** — an undocumented field carries - /// no compatibility promise, so a switch built on one is a switch that can - /// stop being a switch without notice, silently pasting cleaned-up text to - /// users who turned cleanup off. That is the failure this whole path already - /// shipped once, by a different route. Blurt sent the null until 2026-09-11. - /// Adding an `llm` case back would reintroduce it *and* leave the response's - /// choice unreachable — read `transcript(from:)` first. - let llmInstruction: String? - enum CodingKeys: String, CodingKey { - case sampleRate = "sample_rate" - case channels - case sttPrompt = "stt_prompt" - case keytermsPrompt = "keyterms_prompt" - case llmInstruction = "llm_instruction" - } - - /// Hand-written for the one thing synthesis can't express: an empty - /// `stt_prompt` or `keyterms_prompt` must be *absent*, not `""`/`[]`, and a - /// non-optional string or array always encodes. (`llmInstruction` would - /// omit-if-nil on its own; it is spelled out here so the whole wire shape - /// reads in one place.) A property added above and forgotten here never - /// reaches the wire — which is what the config assertions in the tests - /// catch. - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(sampleRate, forKey: .sampleRate) - try container.encode(channels, forKey: .channels) - if !sttPrompt.isEmpty { - try container.encode(sttPrompt, forKey: .sttPrompt) - } - if !keytermsPrompt.isEmpty { - try container.encode(keytermsPrompt, forKey: .keytermsPrompt) - } - try container.encodeIfPresent(llmInstruction, forKey: .llmInstruction) - } - } - - struct DictationResponse: Decodable { - /// The verbatim transcript — always present, never altered by the LLM. What - /// gets pasted with **enhanced transcripts** off, and the fallback when it - /// is on but the rewrite is unusable. - let text: String - /// The rewritten transcript, or nil when the rewrite failed. Every request - /// asks for one, so — unlike before — nil here means failure rather than - /// possibly a declined rewrite. - let llmResponse: String? - /// `"timeout"` or `"error"` when the rewrite failed, which is the only - /// reason `llm_response` can be null now that every request asks for one. - let llmError: String? - /// The request identifier, which the reference says to "include it when - /// reporting problems" — so it is logged on every round trip - /// (`logServerMetrics`). Without it a user's report of a bad dictation - /// cannot be tied to the request that produced it. - let sessionId: String? - /// The service's own duration for the audio it received. Worth having - /// beside the client's `audioMs`, which is computed from the bytes *sent*: - /// a disagreement means the upload was truncated, which no other signal - /// distinguishes from a user who simply stopped talking. - let audioDurationMs: Double? - /// Total server-side processing time, and the transcription portion of it. - /// The pair localizes a slow dictation that the client's `postSpeechMs` - /// only reports the total of: `request_time_ms` minus `sync_time_ms` is - /// roughly what the rewrite cost, so a regression can be attributed to the - /// network, the STT upstream, or the LLM rather than guessed at. - let requestTimeMs: Double? - let syncTimeMs: Double? - /// **All optional except `text`, and deliberately so**, even though the - /// reference marks `session_id` and `audio_duration_ms` required. A - /// non-optional here turns a field the service stops sending into - /// `AssemblyAIError.malformedResponse` — a failed dictation, for a - /// diagnostic nobody was waiting on. `text` is the only field whose absence - /// means there is nothing to paste. - enum CodingKeys: String, CodingKey { - case text - case llmResponse = "llm_response" - case llmError = "llm_error" - case sessionId = "session_id" - case audioDurationMs = "audio_duration_ms" - case requestTimeMs = "request_time_ms" - case syncTimeMs = "sync_time_ms" - } - } - - /// A dictation API failure body. The reference documents exactly two shapes - /// and says to read both: `{error, error_code}` for most failures (400, 401, - /// 413, 429, 502, 503, 504) and `{status, title, detail}` for the ones relayed - /// from the transcription service — an invalid API key (404, *not* 401) and an - /// unsupported audio format (415). A non-string `detail` (a FastAPI-style - /// validation array) is ignored and the caller falls back to the raw body. - /// - /// **`error` is the field the common failures carry**, and reading it is not - /// optional dressing: 400 is the config-validation status, so a wrong field - /// name lands here as - /// `{"error": "invalid config part: : Extra inputs are not permitted", - /// "error_code": "bad_request"}`. This type consulted `message` and `detail` - /// until 2026-09-11, so every one of those fell through to the raw-body arm - /// and surfaced as JSON. - /// - /// **Exactly these two keys, because they are the two the reference - /// documents.** `message` was the first key read here and is in neither - /// documented shape; it was dropped rather than kept as a harmless fallback, - /// under the same rule that took `llm: null` off the request — Blurt calls - /// this API only the way the docs describe it, on both directions of the - /// wire. A body carrying some third spelling now reaches the user through the - /// raw-body arm below, which is the honest outcome: unrecognized, not silently - /// guessed at. - struct ErrorResponse: Decodable { - let message: String? - - enum CodingKeys: String, CodingKey { - case error, detail - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - // `try? decode` already yields `String?` for a key that is missing, null, - // or the wrong type — `decodeIfPresent` would return `String??` here and - // need flattening back down. - func string(_ key: CodingKeys) -> String? { - try? container.decode(String.self, forKey: key) - } - message = string(.error) ?? string(.detail) - } - } -} diff --git a/Sources/BlurtEngine/STT/SyncSTTLimits.swift b/Sources/BlurtEngine/STT/SyncSTTLimits.swift index a5d6c89f..940604dd 100644 --- a/Sources/BlurtEngine/STT/SyncSTTLimits.swift +++ b/Sources/BlurtEngine/STT/SyncSTTLimits.swift @@ -52,14 +52,6 @@ public enum SyncSTTLimits { /// `DictationSession` applies to the blob `MicCaptureProtocol.stop()` returns. public static let minPCMBytes = minSamples * bytesPerSample - /// Raw S16LE bytes that `seconds` of audio occupies at `sampleRate` — the - /// inverse of `durationMs(ofPCMBytes:)`, and mono for the same reason (see - /// `channelCount`). Here rather than at the call site so the upload pipe's - /// buffer doesn't hand-roll the seconds-to-bytes conversion this type owns. - static func pcmBytes(forSeconds seconds: Double) -> Int { - Int(seconds * Double(sampleRate)) * bytesPerSample - } - /// Milliseconds of audio a raw S16LE byte count represents at `rate`. Both the /// capture side (`MicCapture.stop`) and the upload side /// (`AssemblyAITranscriber.transcribe`) log the clip's duration, and those logs diff --git a/Tests/BlurtEngineTests/APIKeySubmissionTests.swift b/Tests/BlurtEngineTests/APIKeySubmissionTests.swift index 47f9aa4c..9fa98177 100644 --- a/Tests/BlurtEngineTests/APIKeySubmissionTests.swift +++ b/Tests/BlurtEngineTests/APIKeySubmissionTests.swift @@ -1,5 +1,6 @@ import Testing +@testable import AssemblyAI @testable import BlurtEngine /// The validate-then-save flow behind the Save/Update button. The invariant diff --git a/Tests/BlurtEngineTests/APIKeyValidatorTests.swift b/Tests/BlurtEngineTests/APIKeyValidatorTests.swift index 57c00209..8fc55614 100644 --- a/Tests/BlurtEngineTests/APIKeyValidatorTests.swift +++ b/Tests/BlurtEngineTests/APIKeyValidatorTests.swift @@ -1,6 +1,8 @@ import Foundation +import Synchronization import Testing +@testable import AssemblyAI @testable import BlurtEngine // The `APIKeyValidator` half of the `HTTPClientTests` suite. Kept on the same @@ -120,9 +122,21 @@ extension HTTPClientTests { #expect(hits.value == 0) } + @Test("the SDK's validator sends only the agent it's given; Blurt's comes from .blurt()") + func validatorAgentIsTheCallers() async { + let agents = Mutex<[String?]>([]) + let transport = FakeHTTPTransport { request in + agents.withLock { $0.append(request.value(forHTTPHeaderField: "User-Agent")) } + return (200, json(["page_number": "1"])) + } + #expect(await APIKeyValidator(transport: transport).validate("k") == .valid) + #expect(await APIKeyValidator(transport: transport, userAgent: "MyApp/1.0").validate("k") == .valid) + #expect(agents.withLock { $0 } == [nil, "MyApp/1.0"]) + } + // MARK: - helpers private func makeValidator(_ transport: any HTTPTransport) -> APIKeyValidator { - APIKeyValidator(transport: transport) + APIKeyValidator.blurt(transport: transport) } } diff --git a/Tests/BlurtEngineTests/AssemblyAITranscriberMultipartTests.swift b/Tests/BlurtEngineTests/AssemblyAITranscriberMultipartTests.swift index ceae91e9..000299c1 100644 --- a/Tests/BlurtEngineTests/AssemblyAITranscriberMultipartTests.swift +++ b/Tests/BlurtEngineTests/AssemblyAITranscriberMultipartTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// The wire framing of the chunked dictation upload: the three pieces of the diff --git a/Tests/BlurtEngineTests/ChunkedRequestBodyTests.swift b/Tests/BlurtEngineTests/ChunkedRequestBodyTests.swift index 4d6906a9..8399dc6c 100644 --- a/Tests/BlurtEngineTests/ChunkedRequestBodyTests.swift +++ b/Tests/BlurtEngineTests/ChunkedRequestBodyTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// The upload's transport half: the bound-pair body pipe, the send policy that diff --git a/Tests/BlurtEngineTests/KeytermsWireTests.swift b/Tests/BlurtEngineTests/KeytermsWireTests.swift index 7df9477a..e8672e41 100644 --- a/Tests/BlurtEngineTests/KeytermsWireTests.swift +++ b/Tests/BlurtEngineTests/KeytermsWireTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// The two steering fields of the dictation `config`, as they actually encode: @@ -150,7 +151,7 @@ extension HTTPClientTests { /// One config carrying `llmInstruction` and nothing else optional, re-parsed. private func rewriteConfig(_ instruction: String?) throws -> [String: Any] { let data = try JSONEncoder().encode( - AssemblyAITranscriber.DictationConfig( + DictationConfig( sampleRate: 16_000, channels: 1, sttPrompt: "", keytermsPrompt: [], llmInstruction: instruction)) return try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) diff --git a/Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift b/Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift index f5b7eee8..94feb723 100644 --- a/Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift +++ b/Tests/BlurtEngineTests/Stubs/FakeHTTPTransport.swift @@ -1,6 +1,7 @@ import Foundation import Synchronization +@testable import AssemblyAI @testable import BlurtEngine /// Per-instance `HTTPTransport` fake for the HTTP-client suites: answers each diff --git a/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift b/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift index 5d202071..044fd598 100644 --- a/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift +++ b/Tests/BlurtEngineTests/Stubs/TranscriberTestSupport.swift @@ -1,5 +1,6 @@ import Foundation +@testable import AssemblyAI @testable import BlurtEngine /// Scaffolding shared by the transcriber suites, in `Stubs/` for the same reason diff --git a/scripts/mutate.sh b/scripts/mutate.sh index aa203eba..d0f02d8c 100755 --- a/scripts/mutate.sh +++ b/scripts/mutate.sh @@ -46,7 +46,7 @@ cd "$REPO_ROOT" # (nothing exercises those lines) that drown the real signal. DEFAULT_TARGETS=( "Sources/BlurtEngine/Config/APIKeyDisplay.swift" - "Sources/BlurtEngine/Config/APIKeyValidator.swift" + "Sources/AssemblyAI/APIKeyValidator.swift" "Sources/BlurtEngine/FocusCapture/FocusCapture+Pure.swift" "Sources/BlurtEngine/Hotkey/DictationKeyGate.swift" "Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift" From c552cd03a0867373c3a90a9538935d1c2b0723e9 Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 18:11:26 -0500 Subject: [PATCH 5/9] fix(scripts): build the AssemblyAI module in ios-typecheck Co-Authored-By: Claude Opus 5.5 --- scripts/ios-typecheck.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/ios-typecheck.sh b/scripts/ios-typecheck.sh index f610c254..e4134c8d 100755 --- a/scripts/ios-typecheck.sh +++ b/scripts/ios-typecheck.sh @@ -34,9 +34,15 @@ COMMON=( -swift-version 6 -parse-as-library ) +# The AssemblyAI SDK first: the engine imports it. +echo "AssemblyAI → $TARGET" +find "$REPO_ROOT/Sources/AssemblyAI" -name '*.swift' -print0 \ + | xargs -0 xcrun swiftc "${COMMON[@]}" -module-name AssemblyAI \ + -emit-module -emit-module-path "$WORK/AssemblyAI.swiftmodule" + echo "BlurtEngine → $TARGET" find "$REPO_ROOT/Sources/BlurtEngine" -name '*.swift' -print0 \ - | xargs -0 xcrun swiftc "${COMMON[@]}" -module-name BlurtEngine \ + | xargs -0 xcrun swiftc "${COMMON[@]}" -I "$WORK" -module-name BlurtEngine \ -emit-module -emit-module-path "$WORK/BlurtEngine.swiftmodule" IOS="$REPO_ROOT/App/BlurtiOS" From 17b9c316d13d0d72d3bed346be21e01c1bfd725e Mon Sep 17 00:00:00 2001 From: he-james Date: Fri, 2 Oct 2026 18:11:26 -0500 Subject: [PATCH 6/9] refactor(ios): add a BlurtDesign component library Move the iPhone's design system out of the loose Shared/ folder and AppChrome.swift into BlurtDesign, a static, extension-safe framework the app and the keyboard both link: the generated DesignTokens, the type roles, the brand colours, and card/page/brandForm/Eyebrow/ BrandButtonStyle/Wordmark/FilmGrain. The chrome colours are built from the token pairs rather than read from the app's asset catalog, so the library stands alone; a test pins them to the catalog in light and dark. The CTA tokens now alias the accent, so the accent (Blurt green) is one token pair to repoint. iPhone only: the Mac app is unchanged. Co-Authored-By: Claude Opus 5.5 --- .claude/agents/swift6-concurrency-reviewer.md | 2 +- .claude/skills/project-guardrails/SKILL.md | 2 +- AGENTS.md | 8 +- App/BlurtiOS/BlurtDesign/BlurtBrand.swift | 60 ++ .../{Shared => BlurtDesign}/BlurtType.swift | 8 +- .../Chrome.swift} | 63 +-- .../DesignTokens.swift | 517 +++++++++--------- .../FilmGrain.swift} | 13 +- .../BlurtKeyboard/Sources/AddTermKey.swift | 1 + .../Sources/FullKeyboardView.swift | 1 + .../BlurtKeyboard/Sources/KeyCap.swift | 1 + .../BlurtKeyboard/Sources/KeyGeometry.swift | 1 + .../Sources}/KeyboardLayout+Height.swift | 1 + .../Sources/KeyboardPalette.swift | 10 +- .../Sources/KeyboardRootView.swift | 1 + .../Sources/KeyboardViewController.swift | 1 + .../Sources}/MicAlignment+Layout.swift | 0 .../BlurtKeyboard/Sources/MicControl.swift | 1 + .../BlurtKeyboard/Sources/PanelCarousel.swift | 1 + .../BlurtKeyboard/Sources/PanelView.swift | 1 + .../BlurtKeyboard/Sources/SurfaceFinish.swift | 3 +- .../BlurtKeyboard/Sources/TermField.swift | 1 + .../BlurtKeyboard/Sources/VoiceBar.swift | 1 + .../BlurtKeyboard/Sources/VoiceElement.swift | 1 + .../BlurtKeyboard/Sources/VoiceGrille.swift | 1 + .../BlurtKeyboard/Sources/VoiceRibs.swift | 1 + .../BlurtKeyboard/Sources/VoiceRow.swift | 1 + .../BlurtKeyboard/Sources/VoiceStreak.swift | 1 + .../BlurtKeyboard/Sources/WaveformMeter.swift | 1 + .../BlurtiOS/Sources/ConsentView.swift | 1 + App/BlurtiOS/BlurtiOS/Sources/HomeHero.swift | 1 + App/BlurtiOS/BlurtiOS/Sources/HomeView.swift | 1 + .../BlurtiOS/Sources/ImportTermsView.swift | 1 + .../BlurtiOS/Sources/KeyEntryView.swift | 1 + .../BlurtiOS/Sources/KeyboardProbeView.swift | 1 + .../BlurtiOS/Sources/RecentSection.swift | 1 + .../BlurtiOS/Sources/SettingsView.swift | 1 + App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift | 1 + .../BlurtiOS/Sources/StyleChips.swift | 1 + .../BlurtiOS/Sources/StylesView.swift | 5 +- .../BlurtiOS/Sources/ThemePickerView.swift | 1 + .../BlurtiOSTests/AppleGeometryTests.swift | 1 + .../BlurtiOSTests/DesignTokensTests.swift | 38 ++ .../BlurtiOSTests/FeedAndLayoutTests.swift | 1 + .../BlurtiOSTests/SharedStateTests.swift | 1 + .../BlurtiOSTests/VoiceElementTests.swift | 1 + .../BlurtiOSTests/VoiceRowLayoutTests.swift | 1 + App/BlurtiOS/DESIGN.md | 66 +-- App/BlurtiOS/Design/tokens.json | 10 +- App/BlurtiOS/README.md | 5 +- App/BlurtiOS/Shared/BlurtBrand.swift | 20 - App/BlurtiOS/project.yml | 34 +- scripts/check-invariants.sh | 6 +- scripts/check.sh | 2 +- scripts/design-sync.sh | 15 +- scripts/design-tokens.swift | 18 +- scripts/ios-typecheck.sh | 10 +- 57 files changed, 538 insertions(+), 411 deletions(-) create mode 100644 App/BlurtiOS/BlurtDesign/BlurtBrand.swift rename App/BlurtiOS/{Shared => BlurtDesign}/BlurtType.swift (67%) rename App/BlurtiOS/{BlurtiOS/Sources/AppChrome.swift => BlurtDesign/Chrome.swift} (65%) rename App/BlurtiOS/{Shared => BlurtDesign}/DesignTokens.swift (55%) rename App/BlurtiOS/{BlurtKeyboard/Sources/Grain.swift => BlurtDesign/FilmGrain.swift} (68%) rename App/BlurtiOS/{Shared => BlurtKeyboard/Sources}/KeyboardLayout+Height.swift (96%) rename App/BlurtiOS/{Shared => BlurtKeyboard/Sources}/MicAlignment+Layout.swift (100%) delete mode 100644 App/BlurtiOS/Shared/BlurtBrand.swift diff --git a/.claude/agents/swift6-concurrency-reviewer.md b/.claude/agents/swift6-concurrency-reviewer.md index 5c5808fb..07b1758c 100644 --- a/.claude/agents/swift6-concurrency-reviewer.md +++ b/.claude/agents/swift6-concurrency-reviewer.md @@ -58,7 +58,7 @@ Look at the changes (default to the working diff via `git diff` and ### On the iPhone (`App/BlurtiOS/`) - **The keyboard never hears anything:** no `AVFoundation`/`AVFAudio` in - `BlurtKeyboard/`, `Shared/` or `BlurtiOSCore/` (the keyboard links the core). + `BlurtKeyboard/`, `BlurtDesign/` or `BlurtiOSCore/` (the keyboard links the core). Capture lives only in the app (`WindowedAudioSource`, `SimulatorAudioSource`). - **Payloads that cross processes or the capture path are `nonisolated`** — `SharedStore`, `PhaseSnapshot`, `KeyboardCommand`, `DictationResult` and the diff --git a/.claude/skills/project-guardrails/SKILL.md b/.claude/skills/project-guardrails/SKILL.md index cd1fd6c4..b0a2d98b 100644 --- a/.claude/skills/project-guardrails/SKILL.md +++ b/.claude/skills/project-guardrails/SKILL.md @@ -184,7 +184,7 @@ only one of stt_prompt or prompt; they are the same field`, before the audio is - **The keyboard never hears anything.** iOS lets no keyboard use the microphone; the app listens and transcribes, the keyboard is a remote control over the App Group that inserts - the words. No `AVFoundation` in `BlurtKeyboard/`, `Shared/` or `BlurtiOSCore/` (the keyboard + the words. No `AVFoundation` in `BlurtKeyboard/`, `BlurtDesign/` or `BlurtiOSCore/` (the keyboard links it) (mechanized). - The app's microphone is a _listening window_, opened once while the app is in front and kept open, not a per-press capture — the two rows marked "(macOS shell)" above do not apply. diff --git a/AGENTS.md b/AGENTS.md index b7a258f6..3ee88bbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,8 +107,10 @@ App/BlurtiOS/ the iPhone app and its keyboard (README.md there is the mic from the background), HomeView and the other screens BlurtKeyboard/Sources/ the keyboard's views: a remote control that inserts the words; three layouts (slim bar / panel / full keyboard) over BlurtiOSCore's model - Shared/ the UI both targets draw with (DesignTokens, type, brand), compiled - into BOTH + BlurtDesign/ the component library both targets link: the generated DesignTokens, + type roles, brand colours (one accent token), card, page, brandForm, + Eyebrow, BrandButtonStyle, Wordmark, FilmGrain. Static, SwiftUI only, + `package` access; iPhone only for now (the Mac keeps Branding/) Tests/BlurtEngineTests/ Swift Testing suites; Stubs/ holds the seam doubles scripts/ check.sh, check-portability.sh, check-invariants.sh, bootstrap.sh, dev-build.sh, uitest.sh, leaks.sh, release*.sh, @@ -424,7 +426,7 @@ rule along with the row. | Redirect the post-build install away from `/Applications` | TCC won't register apps in DerivedData/`/tmp`, so permission toggles never appear. | | Touch the real Keychain in tests | `APIKeyStore` is the production item — a test that writes it triggers Keychain prompts and corrupts the real item's ACL. Use an isolated service (see `KeychainStoreTests`) or `InMemoryAPIKeyStore`. | | Add backwards-compat shims for removed types | Deleted types stay deleted — no deprecated re-exports. | -| Give the keyboard extension a microphone or any audio type | iOS lets no keyboard use the microphone, and the design keeps every audio type out of it: the app listens and transcribes over its always-on capture, the keyboard is a remote control that inserts the words (`KeyboardRelayInjector`). An `AVFoundation` import in `App/BlurtiOS/BlurtKeyboard`, `Shared` or `BlurtiOSCore` (which the keyboard links) is that decision being reversed by accident. | +| Give the keyboard extension a microphone or any audio type | iOS lets no keyboard use the microphone, and the design keeps every audio type out of it: the app listens and transcribes over its always-on capture, the keyboard is a remote control that inserts the words (`KeyboardRelayInjector`). An `AVFoundation` import in `App/BlurtiOS/BlurtKeyboard`, `BlurtDesign` or `BlurtiOSCore` (which the keyboard links) is that decision being reversed by accident. | | Move the iPhone's logic back into its UI targets, or views into `BlurtiOSCore` | The iPhone code splits the way the Mac's does: `App/BlurtiOS/BlurtiOSCore` is the logic (the App Group contract, `KeyboardModel`, `VoiceState`, `KeyboardInteraction`, term packs, the relay injector) — no SwiftUI, no views, no audio, `package` access, a static framework both targets link — and it alone carries the 88% coverage gate (`scripts/ios-test.sh`), as the engine alone does on the Mac. Logic in a view file escapes that gate; SwiftUI in the core drags the views' colours and tokens into a module the keyboard's memory budget pays for. A UI hook the model needs becomes a shell-side extension (`KeyboardModel+Palette.swift`). | | Deep-link into Settings › General › Keyboard (`App-Prefs:`, `prefs:`) | Private URLs: App Review rejects them (2.5.1) and they break between iOS releases. `UIApplication.openSettingsURLString` — Blurt's own page, whose Keyboards row iOS adds for any app with a keyboard — is the only Settings link; the setup step names the whole path (Apps › Blurt › Keyboards) because some builds (the iOS 26 simulator) open Settings at its top level instead. | | Bundle the brand's fonts in the iPhone app | Modern Gothic Mono, UN 11ST and Oceanic Text were bundled and removed: their licence covers the web, not an app in a public MIT repo. `BlurtType`'s roles (`mono`, `heading`, `body`) map to the system's monospaced, serif and default faces; a face comes back only with written app-embedding rights, through the same roles. | diff --git a/App/BlurtiOS/BlurtDesign/BlurtBrand.swift b/App/BlurtiOS/BlurtDesign/BlurtBrand.swift new file mode 100644 index 00000000..fee3e8b8 --- /dev/null +++ b/App/BlurtiOS/BlurtDesign/BlurtBrand.swift @@ -0,0 +1,60 @@ +import SwiftUI +import UIKit + +/// Blurt's brand colours, by role. The values live in `Design/tokens.json` and +/// arrive through the generated `DesignTokens`; add a colour to the tokens, not +/// here. +/// +/// Two kinds. The app's chrome (`accent`, `page`, `text`, …) adapts to light +/// and dark, from the `app/*-light` and `app/*-dark` token pairs: the same +/// pairs the app's asset catalog is generated from, so system-drawn chrome +/// (the catalog's `AccentColor`) and these agree. The accent is that one pair, +/// `app/accent-*`, which points at Blurt green: repointing it restyles every +/// screen at once. +/// +/// The keyboard's surface is fixed per theme instead, the way the Mac's overlay +/// pill is fixed: it floats over whatever app the user is typing in, so it +/// cannot take its cue from that app's appearance, and it must read the same on +/// a white Notes page and a black Messages thread. Everything on it therefore +/// names fixed shades — `errorOrange`, the palette's — never `accent`. +package enum BlurtBrand { + /// `#E67F36` — the error word. The body stays ink; the word carries the alarm. + package nonisolated static let errorOrange = DesignTokens.Brand.orange + + /// Green in light, the lifted green in dark: the app's accent. + package nonisolated static let accent = adaptive( + DesignTokens.Keyboard.appAccentLight, DesignTokens.Keyboard.appAccentDark) + /// The page: the design system's warm off-white in light, ink in dark. + package nonisolated static let page = adaptive(DesignTokens.Keyboard.appPageLight, DesignTokens.Keyboard.appPageDark) + package nonisolated static let text = adaptive(DesignTokens.Keyboard.appTextLight, DesignTokens.Keyboard.appTextDark) + /// Eyebrows, captions, secondary lines. + package nonisolated static let muted = adaptive( + DesignTokens.Keyboard.appMutedLight, DesignTokens.Keyboard.appMutedDark) + /// The card: warm tint on paper, a step up from ink in dark, with a hairline. + package nonisolated static let cardFill = adaptive( + DesignTokens.Keyboard.appCardFillLight, DesignTokens.Keyboard.appCardFillDark) + package nonisolated static let cardBorder = adaptive( + DesignTokens.Keyboard.appCardBorderLight, DesignTokens.Keyboard.appCardBorderDark) + /// The one accent button per row, and its text. + package nonisolated static let cta = adaptive(DesignTokens.Keyboard.appCtaLight, DesignTokens.Keyboard.appCtaDark) + package nonisolated static let ctaText = adaptive( + DesignTokens.Keyboard.appCtaTextLight, DesignTokens.Keyboard.appCtaTextDark) + + /// A colour that follows the interface style it is drawn in, as an asset + /// catalog colour does, without needing one: the library carries no catalog, + /// and the keyboard's bundle has none of the app's. + private nonisolated static func adaptive(_ light: Color, _ dark: Color) -> Color { + let light = UIColor(light) + let dark = UIColor(dark) + return Color(uiColor: UIColor { $0.userInterfaceStyle == .dark ? dark : light }) + } +} + +extension Color { + /// `Color(hex: 0x1D1B16)`. `nonisolated` so the generated `DesignTokens` + /// statics, which are nonisolated too, can call it. + package nonisolated init(hex: UInt32) { + self.init( + red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255) + } +} diff --git a/App/BlurtiOS/Shared/BlurtType.swift b/App/BlurtiOS/BlurtDesign/BlurtType.swift similarity index 67% rename from App/BlurtiOS/Shared/BlurtType.swift rename to App/BlurtiOS/BlurtDesign/BlurtType.swift index 43b19c1e..95da51c6 100644 --- a/App/BlurtiOS/Shared/BlurtType.swift +++ b/App/BlurtiOS/BlurtDesign/BlurtType.swift @@ -6,17 +6,17 @@ import SwiftUI /// headlines; the default face for body text. Call sites name the role, never /// a face. Fixed point sizes throughout, as the system keyboard's are — no /// Dynamic Type. -enum BlurtType { +package enum BlurtType { /// Eyebrows, CTAs, the keyboard's word labels. - nonisolated static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { + package nonisolated static func mono(_ size: CGFloat, weight: Font.Weight = .regular) -> Font { .system(size: size, weight: weight, design: .monospaced) } /// The app's headlines, sentence case. - nonisolated static func heading(_ size: CGFloat) -> Font { .system(size: size, design: .serif) } + package nonisolated static func heading(_ size: CGFloat) -> Font { .system(size: size, design: .serif) } /// The app's body text. - nonisolated static func body(_ size: CGFloat, bold: Bool = false) -> Font { + package nonisolated static func body(_ size: CGFloat, bold: Bool = false) -> Font { .system(size: size, weight: bold ? .bold : .regular) } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift b/App/BlurtiOS/BlurtDesign/Chrome.swift similarity index 65% rename from App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift rename to App/BlurtiOS/BlurtDesign/Chrome.swift index bb2e5f15..b9d9584b 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift +++ b/App/BlurtiOS/BlurtDesign/Chrome.swift @@ -1,30 +1,13 @@ import SwiftUI import UIKit -/// The app's chrome, on the brand: the appearance-adaptive colours from the -/// asset catalog (generated from the tokens — page, text, muted, the card, -/// the one green button), the card, the eyebrow, the button, the wordmark. -/// The keyboard never uses these: it has its own faces. -extension BlurtBrand { - /// `green` in light, the lifted green in dark — the catalog's `AccentColor`, - /// also the app-level accent (`project.yml`). - nonisolated static let accent = Color.accentColor - /// The page: the design system's warm off-white in light, ink in dark. - nonisolated static let page = Color("Page") - nonisolated static let text = Color("Text") - /// Eyebrows, captions, secondary lines. - nonisolated static let muted = Color("Muted") - /// The card: warm tint on paper, a step up from ink in dark, with a hairline. - nonisolated static let cardFill = Color("CardFill") - nonisolated static let cardBorder = Color("CardBorder") - /// The one green button per row, and its text. - nonisolated static let cta = Color("CTA") - nonisolated static let ctaText = Color("CTAText") -} - +/// The app's chrome, on the brand: the card, the page, the form, the eyebrow, +/// the button, the wordmark. Screens are built from these rather than stock +/// `Form`/`List` styling or default buttons. The keyboard draws its own faces +/// (`KeyboardPalette`) and uses only the grain from here. extension View { /// The brand's card: the warm fill, 12 pt corners, a 1 pt hairline, no shadow. - func card(radius: CGFloat = DesignTokens.Metrics.cardRadius) -> some View { + package func card(radius: CGFloat = DesignTokens.Metrics.cardRadius) -> some View { background(BlurtBrand.cardFill, in: RoundedRectangle(cornerRadius: radius)) .overlay( RoundedRectangle(cornerRadius: radius).strokeBorder( @@ -32,11 +15,11 @@ extension View { } /// The page under a screen: the ground colour with the brand's film grain over it. - func page() -> some View { + package func page() -> some View { background { ZStack { BlurtBrand.page - BlurtiOSGrain(seed: 7) + FilmGrain(seed: 7) .opacity(DesignTokens.Metrics.opacityGrainDark) .blendMode(.overlay) .allowsHitTesting(false) @@ -44,12 +27,10 @@ extension View { .ignoresSafeArea() } } -} -extension View { /// A form or list on the brand: the page under it, the card as each row's /// ground, the body face for its text, the accent for its controls. - func brandForm() -> some View { + package func brandForm() -> some View { scrollContentBackground(.hidden) .page() .listRowBackground(BlurtBrand.cardFill) @@ -61,16 +42,16 @@ extension View { /// An eyebrow: the system mono, uppercase, tracked, muted — the brand's /// section label, above every group on a screen. -struct Eyebrow: View { +package struct Eyebrow: View { let text: String var color = BlurtBrand.muted - init(_ text: String, color: Color = BlurtBrand.muted) { + package init(_ text: String, color: Color = BlurtBrand.muted) { self.text = text self.color = color } - var body: some View { + package var body: some View { Text(text.uppercased()) .font(BlurtType.mono(DesignTokens.Typography.sizeEyebrow, weight: .medium)) .tracking(DesignTokens.Typography.trackingEyebrow) @@ -80,16 +61,18 @@ struct Eyebrow: View { /// The brand's button: 40 pt, 4 pt corners — rectangular, never a pill — /// its label in uppercase mono; a colour change while pressed, no lift. -/// `primary` is the one green button per row; `secondary` a hairline. -struct BrandButtonStyle: ButtonStyle { - enum Role { +/// `primary` is the one accent button per row; `secondary` a hairline. +package struct BrandButtonStyle: ButtonStyle { + package enum Role { case primary case secondary } var role = Role.primary - func makeBody(configuration: Configuration) -> some View { + package init(role: Role = .primary) { self.role = role } + + package func makeBody(configuration: Configuration) -> some View { configuration.label .font(BlurtType.mono(DesignTokens.Typography.sizeCta, weight: .medium)) .tracking(DesignTokens.Typography.trackingCta) @@ -111,12 +94,16 @@ struct BrandButtonStyle: ButtonStyle { } } -/// The lowercase wordmark from the Mac's ready screen, tinted with the accent -/// (`blurt-ready-logo.png`, shared from `Design/brand`). -struct Wordmark: View { +/// The lowercase wordmark from the Mac's ready screen, tinted with the accent. +/// The image (`blurt-ready-logo.png`, shared from `Design/brand`) is the host +/// bundle's resource, not the library's; without it the word is set in the +/// heading face instead. +package struct Wordmark: View { private static let height = DesignTokens.Metrics.wordmarkHeight - var body: some View { + package init() {} + + package var body: some View { if let image = UIImage(named: "blurt-ready-logo") { Image(uiImage: image) .renderingMode(.template) diff --git a/App/BlurtiOS/Shared/DesignTokens.swift b/App/BlurtiOS/BlurtDesign/DesignTokens.swift similarity index 55% rename from App/BlurtiOS/Shared/DesignTokens.swift rename to App/BlurtiOS/BlurtDesign/DesignTokens.swift index dad8de06..1bde0c19 100644 --- a/App/BlurtiOS/Shared/DesignTokens.swift +++ b/App/BlurtiOS/BlurtDesign/DesignTokens.swift @@ -9,496 +9,497 @@ import SwiftUI /// (`Design/tokens.json`): what the views draw with, what DESIGN.md's tables /// say, and what the asset catalog holds, all from the same file. Swift names /// follow the token names by one rule (`kb/key-modifier` → `kbKeyModifier`). -enum DesignTokens { - enum Brand { +/// `package`: BlurtDesign's, read by the app, the keyboard and the tests. +package enum DesignTokens { + package enum Brand { /// The iPhone keyboard's dark key. - nonisolated static let appleDarkKey: Color = Color(hex: 0x6B6B6B) + package nonisolated static let appleDarkKey: Color = Color(hex: 0x6B6B6B) /// The iPhone keyboard's dark modifier key. - nonisolated static let appleDarkModifier: Color = Color(hex: 0x464646) + package nonisolated static let appleDarkModifier: Color = Color(hex: 0x464646) /// The iPhone keyboard's dark surface. - nonisolated static let appleDarkSurface: Color = Color(hex: 0x2B2B2B) + package nonisolated static let appleDarkSurface: Color = Color(hex: 0x2B2B2B) /// The iPhone keyboard's light modifier key. - nonisolated static let appleLightModifier: Color = Color(hex: 0xADB3BC) + package nonisolated static let appleLightModifier: Color = Color(hex: 0xADB3BC) /// The iPhone keyboard's light surface. - nonisolated static let appleLightSurface: Color = Color(hex: 0xD1D5DB) - nonisolated static let black: Color = Color(hex: 0x000000) + package nonisolated static let appleLightSurface: Color = Color(hex: 0xD1D5DB) + package nonisolated static let black: Color = Color(hex: 0x000000) /// The warm card fill; the paper theme's surface. - nonisolated static let card: Color = Color(hex: 0xEBE8E8) + package nonisolated static let card: Color = Color(hex: 0xEBE8E8) /// The card's hairline; the paper theme's modifier key. - nonisolated static let cardBorder: Color = Color(hex: 0xDEDBDB) + package nonisolated static let cardBorder: Color = Color(hex: 0xDEDBDB) /// AssemblyAI's primary violet: the drop, never a key or a surface. - nonisolated static let cobolt: Color = Color(hex: 0x3923C7) + package nonisolated static let cobolt: Color = Color(hex: 0x3923C7) /// The design system's UI-and-code green fill (unused on the phone yet). - nonisolated static let green100: Color = Color(hex: 0xCCE4D5) + package nonisolated static let green100: Color = Color(hex: 0xCCE4D5) /// The design system's UI-and-code green highlight (unused on the phone yet). - nonisolated static let green200: Color = Color(hex: 0x99C8AC) + package nonisolated static let green200: Color = Color(hex: 0x99C8AC) /// The brand hue lifted for dark chrome: the meter, the ring, the wave on ink. - nonisolated static let green400: Color = Color(hex: 0x67AD82) + package nonisolated static let green400: Color = Color(hex: 0x67AD82) /// The wordmark green, light chrome (the app's accent in light). - nonisolated static let green700: Color = Color(hex: 0x01762F) + package nonisolated static let green700: Color = Color(hex: 0x01762F) /// The pale green the orb lightens into. - nonisolated static let greenMist: Color = Color(hex: 0xDBF5E6) + package nonisolated static let greenMist: Color = Color(hex: 0xDBF5E6) /// The brand ink: the Mac pill's body, the ink theme's surface. - nonisolated static let ink: Color = Color(hex: 0x1D1B16) + package nonisolated static let ink: Color = Color(hex: 0x1D1B16) /// The Mac's dark card border. - nonisolated static let ink600: Color = Color(hex: 0x3A362F) + package nonisolated static let ink600: Color = Color(hex: 0x3A362F) /// An ordinary key on ink, one step up. - nonisolated static let ink700: Color = Color(hex: 0x33302A) + package nonisolated static let ink700: Color = Color(hex: 0x33302A) /// A modifier key on ink; the Mac's dark card fill. - nonisolated static let ink800: Color = Color(hex: 0x26231E) + package nonisolated static let ink800: Color = Color(hex: 0x26231E) /// A key on the system's dark keyboard material (measured, iOS 27): the container case. - nonisolated static let materialDarkKey: Color = Color(hex: 0x575757) + package nonisolated static let materialDarkKey: Color = Color(hex: 0x575757) /// A modifier key on the system's dark material. - nonisolated static let materialDarkModifier: Color = Color(hex: 0x474747) + package nonisolated static let materialDarkModifier: Color = Color(hex: 0x474747) /// The system's dark keyboard material (measured, iOS 27). - nonisolated static let materialDarkSurface: Color = Color(hex: 0x373737) + package nonisolated static let materialDarkSurface: Color = Color(hex: 0x373737) /// The system's light keyboard material (measured, iOS 27): what the gallery stands under a clear surface. - nonisolated static let materialLightSurface: Color = Color(hex: 0xE2E3E8) + package nonisolated static let materialLightSurface: Color = Color(hex: 0xE2E3E8) /// Black-100: the lightest warm grey. - nonisolated static let neutral300: Color = Color(hex: 0xD2D1D0) + package nonisolated static let neutral300: Color = Color(hex: 0xD2D1D0) /// Black-200: mono labels on the dark face. - nonisolated static let neutral500: Color = Color(hex: 0xA5A4A2) + package nonisolated static let neutral500: Color = Color(hex: 0xA5A4A2) /// Black-300: muted text and mono labels on the light face. - nonisolated static let neutral700: Color = Color(hex: 0x777673) + package nonisolated static let neutral700: Color = Color(hex: 0x777673) /// Black-400: body text on the light face. - nonisolated static let neutral900: Color = Color(hex: 0x4A4945) + package nonisolated static let neutral900: Color = Color(hex: 0x4A4945) /// The error word and ring, cancel, the Full Access note; never a red body. - nonisolated static let orange: Color = Color(hex: 0xE67F36) + package nonisolated static let orange: Color = Color(hex: 0xE67F36) /// Neutral-100: the light face's surface. - nonisolated static let paper200: Color = Color(hex: 0xECEBE5) + package nonisolated static let paper200: Color = Color(hex: 0xECEBE5) /// Neutral-200: a modifier key on the light face. - nonisolated static let paper300: Color = Color(hex: 0xDAD7CB) + package nonisolated static let paper300: Color = Color(hex: 0xDAD7CB) /// Neutral-300: hairlines and field borders on the light face. - nonisolated static let paper400: Color = Color(hex: 0xC7C3B2) + package nonisolated static let paper400: Color = Color(hex: 0xC7C3B2) /// The design system's page: the app's light ground. - nonisolated static let paperPage: Color = Color(hex: 0xFDFCF8) + package nonisolated static let paperPage: Color = Color(hex: 0xFDFCF8) /// The warm off-white one step in: light cards, dark legends. - nonisolated static let paperTint: Color = Color(hex: 0xF5F3EB) + package nonisolated static let paperTint: Color = Color(hex: 0xF5F3EB) /// The orb gradient's upper violet stop. - nonisolated static let violetIris: Color = Color(hex: 0x887BDD) + package nonisolated static let violetIris: Color = Color(hex: 0x887BDD) /// The orb's lightest violet: its top and the gradient's ends. - nonisolated static let violetLavender: Color = Color(hex: 0xD7D3F4) + package nonisolated static let violetLavender: Color = Color(hex: 0xD7D3F4) /// The orb's mid violet: the drop's halo. - nonisolated static let violetPeriwinkle: Color = Color(hex: 0xB0A7E9) + package nonisolated static let violetPeriwinkle: Color = Color(hex: 0xB0A7E9) /// Key legends on ink: warm white on warm ink. - nonisolated static let warmWhite: Color = Color(hex: 0xF2EEE6) - nonisolated static let white: Color = Color(hex: 0xFFFFFF) + package nonisolated static let warmWhite: Color = Color(hex: 0xF2EEE6) + package nonisolated static let white: Color = Color(hex: 0xFFFFFF) } - enum Themes { + package enum Themes { /// `brand.material/dark-key`. - nonisolated static let darkContainerKey: Color = Brand.materialDarkKey + package nonisolated static let darkContainerKey: Color = Brand.materialDarkKey /// `brand.material/dark-modifier`. - nonisolated static let darkContainerKeyModifier: Color = Brand.materialDarkModifier + package nonisolated static let darkContainerKeyModifier: Color = Brand.materialDarkModifier /// `brand.ink/700`. - nonisolated static let darkField: Color = Brand.ink700 + package nonisolated static let darkField: Color = Brand.ink700 /// `brand.ink/600`. - nonisolated static let darkFieldBorder: Color = Brand.ink600 + package nonisolated static let darkFieldBorder: Color = Brand.ink600 /// `brand.ink/700`. - nonisolated static let darkKey: Color = Brand.ink700 + package nonisolated static let darkKey: Color = Brand.ink700 /// `brand.ink/800`. - nonisolated static let darkKeyModifier: Color = Brand.ink800 + package nonisolated static let darkKeyModifier: Color = Brand.ink800 /// `brand.paper/tint`. - nonisolated static let darkLegend: Color = Brand.paperTint + package nonisolated static let darkLegend: Color = Brand.paperTint /// `brand.neutral/500`. - nonisolated static let darkLegendSecondary: Color = Brand.neutral500 + package nonisolated static let darkLegendSecondary: Color = Brand.neutral500 /// `brand.material/dark-surface`. - nonisolated static let darkMaterial: Color = Brand.materialDarkSurface + package nonisolated static let darkMaterial: Color = Brand.materialDarkSurface /// `brand.orange`. - nonisolated static let darkNotice: Color = Brand.orange + package nonisolated static let darkNotice: Color = Brand.orange /// `brand.ink/700`. - nonisolated static let darkPopup: Color = Brand.ink700 + package nonisolated static let darkPopup: Color = Brand.ink700 /// `brand.green/400`. - nonisolated static let darkSignal: Color = Brand.green400 + package nonisolated static let darkSignal: Color = Brand.green400 /// `brand.ink`. - nonisolated static let darkSurface: Color = Brand.ink + package nonisolated static let darkSurface: Color = Brand.ink /// `brand.white`. - nonisolated static let lightContainerKey: Color = Brand.white + package nonisolated static let lightContainerKey: Color = Brand.white /// `brand.paper/300`. - nonisolated static let lightContainerKeyModifier: Color = Brand.paper300 + package nonisolated static let lightContainerKeyModifier: Color = Brand.paper300 /// `brand.white`. - nonisolated static let lightField: Color = Brand.white + package nonisolated static let lightField: Color = Brand.white /// `brand.paper/400`. - nonisolated static let lightFieldBorder: Color = Brand.paper400 + package nonisolated static let lightFieldBorder: Color = Brand.paper400 /// `brand.white`. - nonisolated static let lightKey: Color = Brand.white + package nonisolated static let lightKey: Color = Brand.white /// `brand.paper/300`. - nonisolated static let lightKeyModifier: Color = Brand.paper300 + package nonisolated static let lightKeyModifier: Color = Brand.paper300 /// `brand.ink`. - nonisolated static let lightLegend: Color = Brand.ink + package nonisolated static let lightLegend: Color = Brand.ink /// `brand.neutral/700`. - nonisolated static let lightLegendSecondary: Color = Brand.neutral700 + package nonisolated static let lightLegendSecondary: Color = Brand.neutral700 /// `brand.material/light-surface`. - nonisolated static let lightMaterial: Color = Brand.materialLightSurface + package nonisolated static let lightMaterial: Color = Brand.materialLightSurface /// `brand.orange`. - nonisolated static let lightNotice: Color = Brand.orange + package nonisolated static let lightNotice: Color = Brand.orange /// `brand.white`. - nonisolated static let lightPopup: Color = Brand.white + package nonisolated static let lightPopup: Color = Brand.white /// `brand.green/700`. - nonisolated static let lightSignal: Color = Brand.green700 + package nonisolated static let lightSignal: Color = Brand.green700 /// `brand.paper/200`. - nonisolated static let lightSurface: Color = Brand.paper200 + package nonisolated static let lightSurface: Color = Brand.paper200 } - enum Keyboard { + package enum Keyboard { /// The catalog's AccentColor in dark. - nonisolated static let appAccentDark: Color = Brand.green400 - /// The catalog's AccentColor in light. - nonisolated static let appAccentLight: Color = Brand.green700 + package nonisolated static let appAccentDark: Color = Brand.green400 + /// The catalog's AccentColor in light: Blurt green, the one accent every screen and the CTA take. + package nonisolated static let appAccentLight: Color = Brand.green700 /// The catalog's CardBorder in dark. - nonisolated static let appCardBorderDark: Color = Brand.ink600 + package nonisolated static let appCardBorderDark: Color = Brand.ink600 /// The catalog's CardBorder in light. - nonisolated static let appCardBorderLight: Color = Brand.paper400 + package nonisolated static let appCardBorderLight: Color = Brand.paper400 /// The catalog's CardFill in dark. - nonisolated static let appCardFillDark: Color = Brand.ink800 + package nonisolated static let appCardFillDark: Color = Brand.ink800 /// The catalog's CardFill in light. - nonisolated static let appCardFillLight: Color = Brand.paperTint - /// The catalog's CTA in dark: the site's button green. - nonisolated static let appCtaDark: Color = Brand.green400 - /// The catalog's CTA in light: the one green button. - nonisolated static let appCtaLight: Color = Brand.green700 + package nonisolated static let appCardFillLight: Color = Brand.paperTint + /// The catalog's CTA in dark: the one accent button. + package nonisolated static let appCtaDark: Color = Keyboard.appAccentDark + /// The catalog's CTA in light: the one accent button. + package nonisolated static let appCtaLight: Color = Keyboard.appAccentLight /// The catalog's CTAText in dark: ink on green, as the site. - nonisolated static let appCtaTextDark: Color = Brand.ink + package nonisolated static let appCtaTextDark: Color = Brand.ink /// The catalog's CTAText in light. - nonisolated static let appCtaTextLight: Color = Brand.white + package nonisolated static let appCtaTextLight: Color = Brand.white /// The catalog's Muted in dark. - nonisolated static let appMutedDark: Color = Brand.neutral500 + package nonisolated static let appMutedDark: Color = Brand.neutral500 /// The catalog's Muted in light: eyebrows, captions, secondary text. - nonisolated static let appMutedLight: Color = Brand.neutral700 + package nonisolated static let appMutedLight: Color = Brand.neutral700 /// The catalog's Page in dark: ink, the website's ground. - nonisolated static let appPageDark: Color = Brand.ink + package nonisolated static let appPageDark: Color = Brand.ink /// The catalog's Page in light: the design system's page. - nonisolated static let appPageLight: Color = Brand.paperPage + package nonisolated static let appPageLight: Color = Brand.paperPage /// The catalog's Text in dark. - nonisolated static let appTextDark: Color = Brand.paperTint + package nonisolated static let appTextDark: Color = Brand.paperTint /// The catalog's Text in light. - nonisolated static let appTextLight: Color = Brand.ink + package nonisolated static let appTextLight: Color = Brand.ink /// The panel's cancel ×. - nonisolated static let kbCancel: Color = Brand.orange + package nonisolated static let kbCancel: Color = Brand.orange /// The key-term field. - nonisolated static let kbField: Color = Themes.darkField + package nonisolated static let kbField: Color = Themes.darkField /// The field's hairline. - nonisolated static let kbFieldBorder: Color = Themes.darkFieldBorder + package nonisolated static let kbFieldBorder: Color = Themes.darkFieldBorder /// The one line of words the keyboard ever shows. - nonisolated static let kbFullAccessNote: Color = Brand.orange + package nonisolated static let kbFullAccessNote: Color = Brand.orange /// An ordinary key. - nonisolated static let kbKey: Color = Themes.darkKey + package nonisolated static let kbKey: Color = Themes.darkKey /// Shift, delete, globe, return, 123, cancel. - nonisolated static let kbKeyModifier: Color = Themes.darkKeyModifier + package nonisolated static let kbKeyModifier: Color = Themes.darkKeyModifier /// Key legends and bare glyphs. - nonisolated static let kbLegend: Color = Themes.darkLegend + package nonisolated static let kbLegend: Color = Themes.darkLegend /// The mono word labels (123, ABC, return, space) and the + at rest. - nonisolated static let kbLegendSecondary: Color = Themes.darkLegendSecondary + package nonisolated static let kbLegendSecondary: Color = Themes.darkLegendSecondary /// The solid ring and glyph for an error. - nonisolated static let kbNoticeError: Color = Brand.orange + package nonisolated static let kbNoticeError: Color = Brand.orange /// The letter pop-up. - nonisolated static let kbPopup: Color = Themes.darkPopup + package nonisolated static let kbPopup: Color = Themes.darkPopup /// The wave, the caret, the saved check. - nonisolated static let kbSignal: Color = Themes.darkSignal + package nonisolated static let kbSignal: Color = Themes.darkSignal /// The design face's surface (Swift reads the palette). - nonisolated static let kbSurface: Color = Themes.darkSurface + package nonisolated static let kbSurface: Color = Themes.darkSurface } - enum Metrics { + package enum Metrics { /// Between the chip's glyph and the word. - nonisolated static let addtermChipGap: CGFloat = 6 + package nonisolated static let addtermChipGap: CGFloat = 6 /// The least room a chip needs beside a centred mic; with less, the mic and the chip centre as a pair. - nonisolated static let addtermChipMinWidth: CGFloat = 96 + package nonisolated static let addtermChipMinWidth: CGFloat = 96 /// The selected-word chip's side padding. - nonisolated static let addtermChipPad: CGFloat = 10 + package nonisolated static let addtermChipPad: CGFloat = 10 /// The cancel × from the panel's far corner when the mic is at an edge. - nonisolated static let addtermInset: CGFloat = 6 + package nonisolated static let addtermInset: CGFloat = 6 /// The brand's button. - nonisolated static let appButtonHeight: CGFloat = 40 + package nonisolated static let appButtonHeight: CGFloat = 40 /// Its side padding. - nonisolated static let appButtonPad: CGFloat = 12 + package nonisolated static let appButtonPad: CGFloat = 12 /// Inside a card. - nonisolated static let appCardPad: CGFloat = 16 + package nonisolated static let appCardPad: CGFloat = 16 /// Between chips. - nonisolated static let appChipGap: CGFloat = 8 + package nonisolated static let appChipGap: CGFloat = 8 /// A style chip's side padding. - nonisolated static let appChipPadX: CGFloat = 12 + package nonisolated static let appChipPadX: CGFloat = 12 /// And its top and bottom. - nonisolated static let appChipPadY: CGFloat = 8 + package nonisolated static let appChipPadY: CGFloat = 8 /// The home header: wordmark and gear. - nonisolated static let appHeaderHeight: CGFloat = 44 + package nonisolated static let appHeaderHeight: CGFloat = 44 /// Between the hero's pieces. - nonisolated static let appHeroGap: CGFloat = 14 + package nonisolated static let appHeroGap: CGFloat = 14 /// Inside the hero card. - nonisolated static let appHeroPad: CGFloat = 20 + package nonisolated static let appHeroPad: CGFloat = 20 /// The header's gear, the copy glyph. - nonisolated static let appIcon: CGFloat = 20 + package nonisolated static let appIcon: CGFloat = 20 /// Between lines of one thought. - nonisolated static let appLineGap: CGFloat = 6 + package nonisolated static let appLineGap: CGFloat = 6 /// The screens' side padding. - nonisolated static let appPagePad: CGFloat = 20 + package nonisolated static let appPagePad: CGFloat = 20 /// Between the theme card's two faces. - nonisolated static let appPreviewGap: CGFloat = 8 + package nonisolated static let appPreviewGap: CGFloat = 8 /// Between the home screen's sections. - nonisolated static let appSectionGap: CGFloat = 24 + package nonisolated static let appSectionGap: CGFloat = 24 /// The setup steps' mono numerals. - nonisolated static let appSetupNumberWidth: CGFloat = 24 + package nonisolated static let appSetupNumberWidth: CGFloat = 24 /// Between a section's eyebrow and its content, and between cards. - nonisolated static let appStackGap: CGFloat = 12 + package nonisolated static let appStackGap: CGFloat = 12 /// The card's hairline. - nonisolated static let cardBorder: CGFloat = 1 + package nonisolated static let cardBorder: CGFloat = 1 /// The app's cards (radius/card). - nonisolated static let cardRadius: CGFloat = 12 - nonisolated static let caretHeight: CGFloat = 20 + package nonisolated static let cardRadius: CGFloat = 12 + package nonisolated static let caretHeight: CGFloat = 20 /// The caret's gap from the text. - nonisolated static let caretLead: CGFloat = 1 - nonisolated static let caretRadius: CGFloat = 1 - nonisolated static let caretWidth: CGFloat = 2 + package nonisolated static let caretLead: CGFloat = 1 + package nonisolated static let caretRadius: CGFloat = 1 + package nonisolated static let caretWidth: CGFloat = 2 /// The +, × and ✓ touch targets: the HIG's 44 pt square around a size/glyph symbol. - nonisolated static let glyphHit: CGFloat = 44 - nonisolated static let grilleBarHeight: CGFloat = 28 + package nonisolated static let glyphHit: CGFloat = 44 + package nonisolated static let grilleBarHeight: CGFloat = 28 /// The grille in the voice bar. - nonisolated static let grilleBarWidth: CGFloat = 64 + package nonisolated static let grilleBarWidth: CGFloat = 64 /// A dot's diameter. - nonisolated static let grilleDot: CGFloat = 2 + package nonisolated static let grilleDot: CGFloat = 2 /// The cross glint's vertical arm, as a fraction of the grille's height. - nonisolated static let grilleGlintArm: CGFloat = 1.6 + package nonisolated static let grilleGlintArm: CGFloat = 1.6 /// The glint's arms. - nonisolated static let grilleGlintWidth: CGFloat = 1 - nonisolated static let grilleHomeHeight: CGFloat = 108 + package nonisolated static let grilleGlintWidth: CGFloat = 1 + package nonisolated static let grilleHomeHeight: CGFloat = 108 /// The grille on the home screen. - nonisolated static let grilleHomeWidth: CGFloat = 240 - nonisolated static let grillePanelHeight: CGFloat = 72 + package nonisolated static let grilleHomeWidth: CGFloat = 240 + package nonisolated static let grillePanelHeight: CGFloat = 72 /// The grille in the panel. - nonisolated static let grillePanelWidth: CGFloat = 160 + package nonisolated static let grillePanelWidth: CGFloat = 160 /// The dot lattice: one dot every. - nonisolated static let grillePitch: CGFloat = 4 + package nonisolated static let grillePitch: CGFloat = 4 /// The sheen's band, as a fraction of the width. - nonisolated static let grilleSheenWidth: CGFloat = 0.35 + package nonisolated static let grilleSheenWidth: CGFloat = 0.35 /// 123 and the globe at 402 pt: the iPhone's. - nonisolated static let keyAbcWidth402: CGFloat = 43.333 + package nonisolated static let keyAbcWidth402: CGFloat = 43.333 /// Between keys, the iPhone's. - nonisolated static let keyGap: CGFloat = 6 + package nonisolated static let keyGap: CGFloat = 6 /// Every key: the iPhone's cap height (apple-geometry.json). - nonisolated static let keyHeight: CGFloat = 43 + package nonisolated static let keyHeight: CGFloat = 43 /// A letter key at 402 pt: (width − 2 × margin/side − 9 × key/gap) / 10. - nonisolated static let keyLetterWidth402: CGFloat = 33.5 + package nonisolated static let keyLetterWidth402: CGFloat = 33.5 /// A key that isn't given a width: the iPhone's 123 key at 402 pt. - nonisolated static let keyMinWidth: CGFloat = 43.333 + package nonisolated static let keyMinWidth: CGFloat = 43.333 /// A legend's side padding on a min-width key. - nonisolated static let keyPad: CGFloat = 4 + package nonisolated static let keyPad: CGFloat = 4 /// Flat keys, one colour at this corner: the iPhone's (fitted 8.17). - nonisolated static let keyRadius: CGFloat = 8 + package nonisolated static let keyRadius: CGFloat = 8 /// The width the @402 tokens were measured at (iPhone 18 Pro); widths scale from it. - nonisolated static let keyReferenceWidth: CGFloat = 402 + package nonisolated static let keyReferenceWidth: CGFloat = 402 /// Return at 402 pt: two 123 keys and a gap. - nonisolated static let keyReturnWidth402: CGFloat = 92.667 + package nonisolated static let keyReturnWidth402: CGFloat = 92.667 /// Shift and delete stand this far from the letters: the iPhone's. - nonisolated static let keySideGap: CGFloat = 13.667 + package nonisolated static let keySideGap: CGFloat = 13.667 /// Shift and delete at 402 pt: what seven letters and the side gaps leave, halved (measured 45.5). - nonisolated static let keySideWidth402: CGFloat = 45.583 + package nonisolated static let keySideWidth402: CGFloat = 45.583 /// Space at 402 pt beside 123, the globe and return: what is left (measured 191.333). - nonisolated static let keySpaceWidth402: CGFloat = 191.667 + package nonisolated static let keySpaceWidth402: CGFloat = 191.667 /// Margin/vertical + voicebar/height + 4 × key/height + 3 × row/gap + margin/bottom-keys. - nonisolated static let layoutFull: CGFloat = 270 + package nonisolated static let layoutFull: CGFloat = 270 /// Chosen: room for the 96 orb over one key row. - nonisolated static let layoutPanel: CGFloat = 216 + package nonisolated static let layoutPanel: CGFloat = 216 /// 2 × margin/vertical + voicebar/height. - nonisolated static let layoutSlim: CGFloat = 60 + package nonisolated static let layoutSlim: CGFloat = 60 /// Under a bottom row of keys (the full keyboard, the panel): the iPhone's. - nonisolated static let marginBottomKeys: CGFloat = 13 + package nonisolated static let marginBottomKeys: CGFloat = 13 /// At the keyboard's sides: the iPhone's cap margin, (402 − 10 letters − 9 gaps) / 2. - nonisolated static let marginSide: CGFloat = 6.5 + package nonisolated static let marginSide: CGFloat = 6.5 /// The keyboard's top, and the slim bar's bottom. - nonisolated static let marginVertical: CGFloat = 8 + package nonisolated static let marginVertical: CGFloat = 8 /// The + without Full Access. - nonisolated static let opacityDisabled: CGFloat = 0.4 + package nonisolated static let opacityDisabled: CGFloat = 0.4 /// The cross glint when the words land. - nonisolated static let opacityGlint: CGFloat = 0.95 + package nonisolated static let opacityGlint: CGFloat = 0.95 /// Film grain over the dark face: matte, the brand's texture. - nonisolated static let opacityGrainDark: CGFloat = 0.14 + package nonisolated static let opacityGrainDark: CGFloat = 0.14 /// Film grain over the light face: paper, lighter. - nonisolated static let opacityGrainLight: CGFloat = 0.08 + package nonisolated static let opacityGrainLight: CGFloat = 0.08 /// The dots at rest, over the key colour. - nonisolated static let opacityGrilleRest: CGFloat = 0.55 - nonisolated static let opacityHairline: CGFloat = 0.5 + package nonisolated static let opacityGrilleRest: CGFloat = 0.55 + package nonisolated static let opacityHairline: CGFloat = 0.5 /// Key legends over the cap; below 1 they sit back. - nonisolated static let opacityLegend: CGFloat = 1 + package nonisolated static let opacityLegend: CGFloat = 1 /// The + at rest. - nonisolated static let opacityLegendMuted: CGFloat = 0.5 + package nonisolated static let opacityLegendMuted: CGFloat = 0.5 /// The element when Blurt isn't ready — dim and still, but there: a tap opens the app. - nonisolated static let opacityOff: CGFloat = 0.6 + package nonisolated static let opacityOff: CGFloat = 0.6 /// The field's placeholder. - nonisolated static let opacityPlaceholder: CGFloat = 0.4 - nonisolated static let opacityPopupShadow: CGFloat = 0.12 + package nonisolated static let opacityPlaceholder: CGFloat = 0.4 + package nonisolated static let opacityPopupShadow: CGFloat = 0.12 /// A key lightens this much while pressed. - nonisolated static let opacityPressBrighten: CGFloat = 0.15 + package nonisolated static let opacityPressBrighten: CGFloat = 0.15 /// A brand button while pressed: a colour change, no lift. - nonisolated static let opacityPressed: CGFloat = 0.85 + package nonisolated static let opacityPressed: CGFloat = 0.85 /// The light passing over the element at rest: chrome catching light. - nonisolated static let opacitySheen: CGFloat = 0.22 + package nonisolated static let opacitySheen: CGFloat = 0.22 /// A soft darkening toward the surface's edges; 0 is none. - nonisolated static let opacitySurfaceVignette: CGFloat = 0 + package nonisolated static let opacitySurfaceVignette: CGFloat = 0 /// The field's ×. - nonisolated static let opacityTermCancel: CGFloat = 0.7 + package nonisolated static let opacityTermCancel: CGFloat = 0.7 /// Under the panel's voice row, above its keys: the top margin again, so the mic is centred between the keyboard's top edge and the keys. - nonisolated static let panelSpacing: CGFloat = 8 + package nonisolated static let panelSpacing: CGFloat = 8 /// The theme card draws the keyboard at this width. - nonisolated static let pickerPreviewWidth: CGFloat = 393 + package nonisolated static let pickerPreviewWidth: CGFloat = 393 /// The theme card's preview corners. - nonisolated static let pickerRadius: CGFloat = 10 + package nonisolated static let pickerRadius: CGFloat = 10 /// Then scales it to fit two across. - nonisolated static let pickerScale: CGFloat = 0.42 + package nonisolated static let pickerScale: CGFloat = 0.42 /// The letter pop-up is the key width plus this. - nonisolated static let popupExtraWidth: CGFloat = 18 - nonisolated static let popupHeight: CGFloat = 56 + package nonisolated static let popupExtraWidth: CGFloat = 18 + package nonisolated static let popupHeight: CGFloat = 56 /// The pop-up rises this far from the key's top: it overlaps the key by popup/height minus this and stays inside the keyboard on the top row. - nonisolated static let popupOffset: CGFloat = 50 - nonisolated static let popupRadius: CGFloat = 10 + package nonisolated static let popupOffset: CGFloat = 50 + package nonisolated static let popupRadius: CGFloat = 10 /// The one thing that floats. - nonisolated static let popupShadowRadius: CGFloat = 8 - nonisolated static let popupShadowY: CGFloat = 2 + package nonisolated static let popupShadowRadius: CGFloat = 8 + package nonisolated static let popupShadowY: CGFloat = 2 /// The brand's buttons and the grille: rectangular, never a pill. - nonisolated static let radiusButton: CGFloat = 4 + package nonisolated static let radiusButton: CGFloat = 4 /// The brand's cards. - nonisolated static let radiusCard: CGFloat = 12 + package nonisolated static let radiusCard: CGFloat = 12 /// The brand's featured cards. - nonisolated static let radiusHero: CGFloat = 16 + package nonisolated static let radiusHero: CGFloat = 16 /// The brand's inputs: the key-term field. - nonisolated static let radiusInput: CGFloat = 8 - nonisolated static let ribsRestHeight: CGFloat = 16 + package nonisolated static let radiusInput: CGFloat = 8 + package nonisolated static let ribsRestHeight: CGFloat = 16 /// The ribs asleep: the wave's own bars at rest. - nonisolated static let ribsRestWidth: CGFloat = 40 + package nonisolated static let ribsRestWidth: CGFloat = 40 /// Between rows, the iPhone's. - nonisolated static let rowGap: CGFloat = 11 + package nonisolated static let rowGap: CGFloat = 11 /// The landing flash's vertical arm, as a fraction of the box's height. - nonisolated static let streakArm: CGFloat = 1.4 + package nonisolated static let streakArm: CGFloat = 1.4 /// The light streak while recording. - nonisolated static let streakHeight: CGFloat = 2 + package nonisolated static let streakHeight: CGFloat = 2 /// The hairline at rest. - nonisolated static let streakLine: CGFloat = 1 + package nonisolated static let streakLine: CGFloat = 1 /// The bright point at the centre. - nonisolated static let streakPoint: CGFloat = 4 + package nonisolated static let streakPoint: CGFloat = 4 /// 1 paints the face's surface under the keys; 0 leaves it clear, so the host's own keyboard material (iOS 26's rounded container, which insets a third-party keyboard and cannot be painted over) is the surface, seamlessly, and the keys take the container colours. - nonisolated static let surfacePaint: CGFloat = 0 + package nonisolated static let surfacePaint: CGFloat = 0 /// Where it is full. - nonisolated static let surfaceVignetteEnd: CGFloat = 0.75 + package nonisolated static let surfaceVignetteEnd: CGFloat = 0.75 /// Where the vignette begins, as a fraction of the keyboard's width. - nonisolated static let surfaceVignetteStart: CGFloat = 0.3 + package nonisolated static let surfaceVignetteStart: CGFloat = 0.3 /// The field's hairline. - nonisolated static let termBorder: CGFloat = 1 + package nonisolated static let termBorder: CGFloat = 1 /// × · field · ✓. - nonisolated static let termGap: CGFloat = 8 + package nonisolated static let termGap: CGFloat = 8 /// The key-term field's capsule. - nonisolated static let termHeight: CGFloat = 36 + package nonisolated static let termHeight: CGFloat = 36 /// The field row's side inset. - nonisolated static let termInset: CGFloat = 2 + package nonisolated static let termInset: CGFloat = 2 /// The field's side padding. - nonisolated static let termPad: CGFloat = 14 + package nonisolated static let termPad: CGFloat = 14 /// The key-term field: the brand's input corners (radius/input). - nonisolated static let termRadius: CGFloat = 8 - nonisolated static let voiceBarHeight: CGFloat = 32 + package nonisolated static let termRadius: CGFloat = 8 + package nonisolated static let voiceBarHeight: CGFloat = 32 /// The voice element's box in the voice bar — the most it takes (the ribs awake, the streak); the + sits beside what shows. - nonisolated static let voiceBarWidth: CGFloat = 160 - nonisolated static let voiceHomeHeight: CGFloat = 112 + package nonisolated static let voiceBarWidth: CGFloat = 160 + package nonisolated static let voiceHomeHeight: CGFloat = 112 /// The box on the home screen. - nonisolated static let voiceHomeWidth: CGFloat = 280 - nonisolated static let voicePanelHeight: CGFloat = 88 + package nonisolated static let voiceHomeWidth: CGFloat = 280 + package nonisolated static let voicePanelHeight: CGFloat = 88 /// The box in the panel. - nonisolated static let voicePanelWidth: CGFloat = 300 + package nonisolated static let voicePanelWidth: CGFloat = 300 /// The element while pressed. - nonisolated static let voicePressScale: CGFloat = 0.94 + package nonisolated static let voicePressScale: CGFloat = 0.94 /// Between the mic key's edge and the add key's box (the + or the chip), the key gap. - nonisolated static let voicebarAddtermClearance: CGFloat = 6 + package nonisolated static let voicebarAddtermClearance: CGFloat = 6 /// The voice row, where the system puts its suggestion bar. - nonisolated static let voicebarHeight: CGFloat = 44 + package nonisolated static let voicebarHeight: CGFloat = 44 /// Between the orb and the Full Access note. - nonisolated static let voicebarNoteGap: CGFloat = 10 + package nonisolated static let voicebarNoteGap: CGFloat = 10 /// A wave bar's width. - nonisolated static let waveBar: CGFloat = 2 - nonisolated static let waveBarHeight: CGFloat = 24 + package nonisolated static let waveBar: CGFloat = 2 + package nonisolated static let waveBarHeight: CGFloat = 24 /// Between wave bars. - nonisolated static let waveGap: CGFloat = 2 - nonisolated static let waveHomeHeight: CGFloat = 44 - nonisolated static let wavePanelHeight: CGFloat = 40 - nonisolated static let wordmarkHeight: CGFloat = 22 + package nonisolated static let waveGap: CGFloat = 2 + package nonisolated static let waveHomeHeight: CGFloat = 44 + package nonisolated static let wavePanelHeight: CGFloat = 40 + package nonisolated static let wordmarkHeight: CGFloat = 22 } - enum Typography { + package enum Typography { /// The clipboard and exclamation glyphs, as a fraction of the voice element's height. - nonisolated static let ratioVoiceGlyph: CGFloat = 0.34 + package nonisolated static let ratioVoiceGlyph: CGFloat = 0.34 /// The app's body text. - nonisolated static let sizeBody: CGFloat = 16 + package nonisolated static let sizeBody: CGFloat = 16 /// The app's small text. - nonisolated static let sizeCaption: CGFloat = 14 + package nonisolated static let sizeCaption: CGFloat = 14 /// A mono button label in the app (E2). - nonisolated static let sizeCta: CGFloat = 14 + package nonisolated static let sizeCta: CGFloat = 14 /// A mono eyebrow in the app (E1). - nonisolated static let sizeEyebrow: CGFloat = 12 + package nonisolated static let sizeEyebrow: CGFloat = 12 /// The +, × and ✓. - nonisolated static let sizeGlyph: CGFloat = 17 + package nonisolated static let sizeGlyph: CGFloat = 17 /// The mono word labels on keys: 123, ABC, #+=, space, the return label. - nonisolated static let sizeLabel: CGFloat = 12 + package nonisolated static let sizeLabel: CGFloat = 12 /// Every other key. - nonisolated static let sizeLegend: CGFloat = 16 + package nonisolated static let sizeLegend: CGFloat = 16 /// Letter keys: the iPhone's (estimated from the l glyph, apple-geometry.json). - nonisolated static let sizeLetter: CGFloat = 24 + package nonisolated static let sizeLetter: CGFloat = 24 /// The letter pop-up. - nonisolated static let sizePopup: CGFloat = 32 + package nonisolated static let sizePopup: CGFloat = 32 /// The key-term field. - nonisolated static let sizeTerm: CGFloat = 17 + package nonisolated static let sizeTerm: CGFloat = 17 /// The app's serif headline. - nonisolated static let sizeTitle: CGFloat = 34 + package nonisolated static let sizeTitle: CGFloat = 34 /// Uppercase mono at 14: the brand's E2 tracking. - nonisolated static let trackingCta: CGFloat = 1.4 + package nonisolated static let trackingCta: CGFloat = 1.4 /// Uppercase mono at 12: the brand's E1 tracking. - nonisolated static let trackingEyebrow: CGFloat = 1.2 - nonisolated static let weightGlyph: Font.Weight = .medium - nonisolated static let weightLabel: Font.Weight = .medium - nonisolated static let weightLegend: Font.Weight = .medium - nonisolated static let weightLetter: Font.Weight = .regular - nonisolated static let weightPopup: Font.Weight = .regular - nonisolated static let weightTerm: Font.Weight = .regular - nonisolated static let weightVoiceGlyph: Font.Weight = .semibold + package nonisolated static let trackingEyebrow: CGFloat = 1.2 + package nonisolated static let weightGlyph: Font.Weight = .medium + package nonisolated static let weightLabel: Font.Weight = .medium + package nonisolated static let weightLegend: Font.Weight = .medium + package nonisolated static let weightLetter: Font.Weight = .regular + package nonisolated static let weightPopup: Font.Weight = .regular + package nonisolated static let weightTerm: Font.Weight = .regular + package nonisolated static let weightVoiceGlyph: Font.Weight = .semibold } - enum Motion { + package enum Motion { /// The caret's blink. - nonisolated static let caret: Double = 0.5 + package nonisolated static let caret: Double = 0.5 /// Holding the chip opens the field instead of adding; longer than a firm tap. - nonisolated static let chipHold: Double = 0.6 + package nonisolated static let chipHold: Double = 0.6 /// A colour or fill changing state: the brand's transition. - nonisolated static let colour: Double = 0.2 + package nonisolated static let colour: Double = 0.2 /// The house curve for everything that moves; nothing springs. - nonisolated static let easeSignature: UnitCurve = UnitCurve.bezier( + package nonisolated static let easeSignature: UnitCurve = UnitCurve.bezier( startControlPoint: UnitPoint(x: 0.22, y: 1), endControlPoint: UnitPoint(x: 0.36, y: 1)) /// The panel's carousel. - nonisolated static let flip: Double = 0.25 + package nonisolated static let flip: Double = 0.25 /// A facet's flash while recording. - nonisolated static let glintLife: Double = 0.5 + package nonisolated static let glintLife: Double = 0.5 /// The keyboard resizing. - nonisolated static let heightChange: Double = 0.25 + package nonisolated static let heightChange: Double = 0.25 /// A key lighting. - nonisolated static let keyPress: Double = 0.08 + package nonisolated static let keyPress: Double = 0.08 /// The glint when the words land. - nonisolated static let landing: Double = 0.6 + package nonisolated static let landing: Double = 0.6 /// The orb's press; the one thing that answers at once. - nonisolated static let press: Double = 0.1 + package nonisolated static let press: Double = 0.1 /// One turn of the ring, the Mac's cadence. - nonisolated static let ringPeriod: Double = 1.6 + package nonisolated static let ringPeriod: Double = 1.6 /// One pass of the light over the element at rest. - nonisolated static let sheen: Double = 4 + package nonisolated static let sheen: Double = 4 /// And while something is happening: the ring's cadence. - nonisolated static let sheenWorking: Double = 1.6 + package nonisolated static let sheenWorking: Double = 1.6 /// Every other change on the key: the ring, a glyph, the dimming. - nonisolated static let stateFade: Double = 0.5 + package nonisolated static let stateFade: Double = 0.5 /// The voice bar becoming the field. - nonisolated static let termSwap: Double = 0.4 + package nonisolated static let termSwap: Double = 0.4 /// The orb and the wave crossing, either way. - nonisolated static let waveFade: Double = 0.7 + package nonisolated static let waveFade: Double = 0.7 } - enum Gradients { + package enum Gradients { } /// Every token as `group.name: value`, the way tokens.json renders it, so a /// test can pin the compiled values to the file (`DesignTokensTests`). - nonisolated static let manifest: [String: String] = [ + package nonisolated static let manifest: [String: String] = [ "brand.apple/dark-key": "#6B6B6B", "brand.apple/dark-modifier": "#464646", "brand.apple/dark-surface": "#2B2B2B", diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/Grain.swift b/App/BlurtiOS/BlurtDesign/FilmGrain.swift similarity index 68% rename from App/BlurtiOS/BlurtKeyboard/Sources/Grain.swift rename to App/BlurtiOS/BlurtDesign/FilmGrain.swift index 0981d8c4..9ecc8d38 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/Grain.swift +++ b/App/BlurtiOS/BlurtDesign/FilmGrain.swift @@ -1,13 +1,16 @@ import SwiftUI /// Film grain: a scatter of faint dots drawn from a seed, so a frame is -/// reproducible — the brand's texture, on the keyboard's surface -/// (`SurfaceFinish`), still, one seed. Drawn synchronously: a canvas that -/// renders asynchronously never presents inside a keyboard extension. -struct BlurtiOSGrain: View { +/// reproducible — the brand's texture, under the app's pages (`page()`) and on +/// the keyboard's surface (`SurfaceFinish`), still, one seed. Drawn +/// synchronously: a canvas that renders asynchronously never presents inside a +/// keyboard extension. +package struct FilmGrain: View { let seed: Int - var body: some View { + package init(seed: Int) { self.seed = seed } + + package var body: some View { Canvas { context, size in var state = UInt64(truncatingIfNeeded: seed &* 6_364_136_223_846_793_005 &+ 1) func next() -> CGFloat { diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/AddTermKey.swift b/App/BlurtiOS/BlurtKeyboard/Sources/AddTermKey.swift index 2db8dc35..6a6773a7 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/AddTermKey.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/AddTermKey.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/FullKeyboardView.swift b/App/BlurtiOS/BlurtKeyboard/Sources/FullKeyboardView.swift index 66b7b935..b6acc524 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/FullKeyboardView.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/FullKeyboardView.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/KeyCap.swift b/App/BlurtiOS/BlurtKeyboard/Sources/KeyCap.swift index 2360179f..29cc8c3a 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/KeyCap.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/KeyCap.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/KeyGeometry.swift b/App/BlurtiOS/BlurtKeyboard/Sources/KeyGeometry.swift index b14dd565..0243684e 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/KeyGeometry.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/KeyGeometry.swift @@ -1,3 +1,4 @@ +import BlurtDesign import CoreFoundation /// The full keyboard's widths, the iPhone's own: ten letter caps across at diff --git a/App/BlurtiOS/Shared/KeyboardLayout+Height.swift b/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardLayout+Height.swift similarity index 96% rename from App/BlurtiOS/Shared/KeyboardLayout+Height.swift rename to App/BlurtiOS/BlurtKeyboard/Sources/KeyboardLayout+Height.swift index af27d156..191c024e 100644 --- a/App/BlurtiOS/Shared/KeyboardLayout+Height.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardLayout+Height.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import CoreFoundation diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardPalette.swift b/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardPalette.swift index a55e4f66..5e904786 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardPalette.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardPalette.swift @@ -1,3 +1,4 @@ +import BlurtDesign import SwiftUI /// The colours the keys draw with: the brand's two faces. The keyboard follows @@ -109,15 +110,6 @@ struct KeyboardPalette: Equatable, Identifiable { static func == (lhs: KeyboardPalette, rhs: KeyboardPalette) -> Bool { lhs.id == rhs.id && lhs.face == rhs.face } } -extension Color { - /// `Color(hex: 0x1D1B16)`. `nonisolated` so the generated `DesignTokens` - /// statics, which are nonisolated too, can call it. - nonisolated init(hex: UInt32) { - self.init( - red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255) - } -} - private struct KeyboardInContainerKey: EnvironmentKey { static let defaultValue = false } diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardRootView.swift b/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardRootView.swift index 111b6815..58bef3d4 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardRootView.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardRootView.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardViewController.swift b/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardViewController.swift index f3b2345c..828fb2b2 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardViewController.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/KeyboardViewController.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI import UIKit diff --git a/App/BlurtiOS/Shared/MicAlignment+Layout.swift b/App/BlurtiOS/BlurtKeyboard/Sources/MicAlignment+Layout.swift similarity index 100% rename from App/BlurtiOS/Shared/MicAlignment+Layout.swift rename to App/BlurtiOS/BlurtKeyboard/Sources/MicAlignment+Layout.swift diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/MicControl.swift b/App/BlurtiOS/BlurtKeyboard/Sources/MicControl.swift index d57bd875..0b77a065 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/MicControl.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/MicControl.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/PanelCarousel.swift b/App/BlurtiOS/BlurtKeyboard/Sources/PanelCarousel.swift index a3c18d51..d8ca5463 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/PanelCarousel.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/PanelCarousel.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/PanelView.swift b/App/BlurtiOS/BlurtKeyboard/Sources/PanelView.swift index da574873..03c76406 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/PanelView.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/PanelView.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/SurfaceFinish.swift b/App/BlurtiOS/BlurtKeyboard/Sources/SurfaceFinish.swift index d16a91f8..9e46c71e 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/SurfaceFinish.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/SurfaceFinish.swift @@ -1,3 +1,4 @@ +import BlurtDesign import SwiftUI /// The surface's finish. Under the keys: the face's flat colour and, when @@ -30,7 +31,7 @@ enum SurfaceFinish { @Environment(\.keyboardPalette) private var palette var body: some View { - BlurtiOSGrain(seed: 7) + FilmGrain(seed: 7) .opacity(palette.grain) .blendMode(.overlay) .allowsHitTesting(false) diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/TermField.swift b/App/BlurtiOS/BlurtKeyboard/Sources/TermField.swift index 9e02ec4d..e386c5eb 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/TermField.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/TermField.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/VoiceBar.swift b/App/BlurtiOS/BlurtKeyboard/Sources/VoiceBar.swift index 215ebc44..1e8565c1 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/VoiceBar.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/VoiceBar.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/VoiceElement.swift b/App/BlurtiOS/BlurtKeyboard/Sources/VoiceElement.swift index 9512cb81..17936c54 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/VoiceElement.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/VoiceElement.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/VoiceGrille.swift b/App/BlurtiOS/BlurtKeyboard/Sources/VoiceGrille.swift index 27e00751..5fbc9565 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/VoiceGrille.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/VoiceGrille.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtEngine import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/VoiceRibs.swift b/App/BlurtiOS/BlurtKeyboard/Sources/VoiceRibs.swift index 43192bd5..155a28e6 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/VoiceRibs.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/VoiceRibs.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/VoiceRow.swift b/App/BlurtiOS/BlurtKeyboard/Sources/VoiceRow.swift index 2dfa27ec..a79e12bb 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/VoiceRow.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/VoiceRow.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/VoiceStreak.swift b/App/BlurtiOS/BlurtKeyboard/Sources/VoiceStreak.swift index cd755bed..a89c72a4 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/VoiceStreak.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/VoiceStreak.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtKeyboard/Sources/WaveformMeter.swift b/App/BlurtiOS/BlurtKeyboard/Sources/WaveformMeter.swift index 0d5fa14b..2a3be3bb 100644 --- a/App/BlurtiOS/BlurtKeyboard/Sources/WaveformMeter.swift +++ b/App/BlurtiOS/BlurtKeyboard/Sources/WaveformMeter.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtEngine import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift b/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift index bc83ca04..dae43559 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/ConsentView.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeHero.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeHero.swift index 2d01121f..bad9c87c 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeHero.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeHero.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index dba9d8db..168af9ff 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -1,4 +1,5 @@ import AVFoundation +import BlurtDesign import BlurtEngine import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtiOS/Sources/ImportTermsView.swift b/App/BlurtiOS/BlurtiOS/Sources/ImportTermsView.swift index d88de63d..f5557456 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/ImportTermsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/ImportTermsView.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift b/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift index 0781be08..42e0a75a 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/KeyEntryView.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtEngine import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtiOS/Sources/KeyboardProbeView.swift b/App/BlurtiOS/BlurtiOS/Sources/KeyboardProbeView.swift index 280ba2b3..b13d0ab2 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/KeyboardProbeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/KeyboardProbeView.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore #if DEBUG diff --git a/App/BlurtiOS/BlurtiOS/Sources/RecentSection.swift b/App/BlurtiOS/BlurtiOS/Sources/RecentSection.swift index 5fe2d426..b281c348 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/RecentSection.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/RecentSection.swift @@ -1,3 +1,4 @@ +import BlurtDesign import SwiftUI import UIKit diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 5f5a5e6d..3b45b100 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtEngine import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift b/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift index 9ca3adc9..f64ed81c 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift @@ -1,4 +1,5 @@ import AVFoundation +import BlurtDesign import BlurtEngine import SwiftUI import UIKit diff --git a/App/BlurtiOS/BlurtiOS/Sources/StyleChips.swift b/App/BlurtiOS/BlurtiOS/Sources/StyleChips.swift index 689a3409..5c0f27a0 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/StyleChips.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/StyleChips.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtEngine import SwiftUI diff --git a/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift b/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift index 07a18965..d8a00b25 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/StylesView.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtEngine import SwiftUI @@ -28,7 +29,7 @@ struct StylesView: View { store.activate(profile) } .swipeActions { - Button("Edit") { editing = profile }.tint(BlurtBrand.green) + Button("Edit") { editing = profile }.tint(BlurtBrand.accent) } } } header: { @@ -63,7 +64,7 @@ private struct StyleRow: View { .lineLimit(2) // literal-ok: two lines of instructions } Spacer() - if isActive { Image(systemName: "checkmark").foregroundStyle(BlurtBrand.green) } + if isActive { Image(systemName: "checkmark").foregroundStyle(BlurtBrand.accent) } } } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/ThemePickerView.swift b/App/BlurtiOS/BlurtiOS/Sources/ThemePickerView.swift index de227131..1fc79fce 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/ThemePickerView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/ThemePickerView.swift @@ -1,3 +1,4 @@ +import BlurtDesign import BlurtiOSCore import SwiftUI diff --git a/App/BlurtiOS/BlurtiOSTests/AppleGeometryTests.swift b/App/BlurtiOS/BlurtiOSTests/AppleGeometryTests.swift index ce973c4a..fc524985 100644 --- a/App/BlurtiOS/BlurtiOSTests/AppleGeometryTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/AppleGeometryTests.swift @@ -1,3 +1,4 @@ +import BlurtDesign import Foundation import Testing diff --git a/App/BlurtiOS/BlurtiOSTests/DesignTokensTests.swift b/App/BlurtiOS/BlurtiOSTests/DesignTokensTests.swift index f64cbb8d..322e1352 100644 --- a/App/BlurtiOS/BlurtiOSTests/DesignTokensTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/DesignTokensTests.swift @@ -1,6 +1,8 @@ +import BlurtDesign import Foundation import SwiftUI import Testing +import UIKit @testable import BlurtiOS @testable import BlurtiOSCore @@ -153,4 +155,40 @@ struct DesignTokensTests { #expect(DesignTokens.Themes.lightNotice == DesignTokens.Brand.orange) #expect(DesignTokens.Themes.darkNotice == DesignTokens.Brand.orange) } + + @Test( + "BlurtDesign's chrome colours are the app's catalog colours, light and dark", + arguments: [ + ("AccentColor", BlurtBrand.accent), ("Page", BlurtBrand.page), ("Text", BlurtBrand.text), + ("Muted", BlurtBrand.muted), ("CardFill", BlurtBrand.cardFill), ("CardBorder", BlurtBrand.cardBorder), + ("CTA", BlurtBrand.cta), ("CTAText", BlurtBrand.ctaText), + ]) + func chromeMatchesCatalog(name: String, colour: Color) throws { + // The catalog is what the system draws with (the global accent, the launch + // screen); the library is what our views draw with. Both come from the same + // token pair, so a mismatch is a generator or library bug. + let catalog = try #require(UIColor(named: name), "\(name) is not in the app's catalog") + for style in [UIUserInterfaceStyle.light, .dark] { + let traits = UITraitCollection(userInterfaceStyle: style) + #expect( + Self.rgba(UIColor(colour).resolvedColor(with: traits)) == Self.rgba(catalog.resolvedColor(with: traits)), + "\(name) in \(style == .dark ? "dark" : "light")") + } + } + + @Test("the CTA is the accent, so the accent is one token to repoint") + func ctaIsTheAccent() { + #expect(DesignTokens.Keyboard.appCtaLight == DesignTokens.Keyboard.appAccentLight) + #expect(DesignTokens.Keyboard.appCtaDark == DesignTokens.Keyboard.appAccentDark) + } + + /// sRGB components to 8 bits, as the catalog stores them. + private static func rgba(_ colour: UIColor) -> [Int] { + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + colour.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return [red, green, blue, alpha].map { Int(($0 * 255).rounded()) } + } } diff --git a/App/BlurtiOS/BlurtiOSTests/FeedAndLayoutTests.swift b/App/BlurtiOS/BlurtiOSTests/FeedAndLayoutTests.swift index 3d377dc7..50f314c9 100644 --- a/App/BlurtiOS/BlurtiOSTests/FeedAndLayoutTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/FeedAndLayoutTests.swift @@ -1,3 +1,4 @@ +import BlurtDesign import Foundation import Testing diff --git a/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift b/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift index 134d6ad8..8a68bc7a 100644 --- a/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift @@ -1,3 +1,4 @@ +import BlurtDesign import Foundation import SwiftUI import Testing diff --git a/App/BlurtiOS/BlurtiOSTests/VoiceElementTests.swift b/App/BlurtiOS/BlurtiOSTests/VoiceElementTests.swift index e84b8500..8358a65f 100644 --- a/App/BlurtiOS/BlurtiOSTests/VoiceElementTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/VoiceElementTests.swift @@ -1,3 +1,4 @@ +import BlurtDesign import Foundation import Testing diff --git a/App/BlurtiOS/BlurtiOSTests/VoiceRowLayoutTests.swift b/App/BlurtiOS/BlurtiOSTests/VoiceRowLayoutTests.swift index 14b63e35..ed4bb79e 100644 --- a/App/BlurtiOS/BlurtiOSTests/VoiceRowLayoutTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/VoiceRowLayoutTests.swift @@ -1,3 +1,4 @@ +import BlurtDesign import CoreFoundation import Testing diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index d89286a1..aaf3d615 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -12,7 +12,7 @@ Sources of truth: the brand design system (`https://www.assemblyai.com/brand-design-system/assemblyai-brand-design-system.md`) and the Blurt landing page for the look; `Design/apple-geometry.json` for the keys; `Design/tokens.json` for every value the views draw with. From the -tokens, `scripts/design-tokens.swift` writes `Shared/DesignTokens.swift` +tokens, `scripts/design-tokens.swift` writes `BlurtDesign/DesignTokens.swift` (what the views read), the tables in this file marked `tokens:begin` / `tokens:end`, and the app's asset-catalog colour sets. Edit the JSON, run `scripts/design-sync.sh`, commit; `check.sh` runs `design-sync.sh --check` and fails on drift or on a design literal in a @@ -412,11 +412,11 @@ side, and — in the panel — cancel in the far top corner, clear of them both, where in the middle it mirrors the + on the mic's other side. Left and right are the phone's sides: in a right-to-left language the leading/trailing mapping flips so the mic stays under the thumb chosen -(`BlurtiOSCore/MicAlignment.swift` and `Shared/MicAlignment+Layout.swift`, tested). Gallery: `-BlurtGalleryAlign +(`BlurtiOSCore/MicAlignment.swift` and `BlurtKeyboard/Sources/MicAlignment+Layout.swift`, tested). Gallery: `-BlurtGalleryAlign left|center|right`; the real extension: `scripts/ios-keyboard-flows.sh --align left|right` checks the mic is at that edge with the add key inside it. -## Tokens (`Design/tokens.json` → `Shared/DesignTokens.swift`) +## Tokens (`Design/tokens.json` → `BlurtDesign/DesignTokens.swift`) The brand's colours, as the design system defines them and as the phone uses them. `BlurtBrand` keeps a few of the Mac's names as aliases. @@ -469,36 +469,36 @@ The keyboard's semantic colours and the app's catalog colours: -| Token | Value | Swift | Use | -| ----------------------- | ------------------------------------------ | ----------------------------- | ---------------------------------------------------------------- | -| `app/accent-dark` | `#67AD82` (`brand.green/400`) | `Keyboard.appAccentDark` | the catalog's AccentColor in dark | -| `app/accent-light` | `#01762F` (`brand.green/700`) | `Keyboard.appAccentLight` | the catalog's AccentColor in light | -| `app/card-border-dark` | `#3A362F` (`brand.ink/600`) | `Keyboard.appCardBorderDark` | the catalog's CardBorder in dark | -| `app/card-border-light` | `#C7C3B2` (`brand.paper/400`) | `Keyboard.appCardBorderLight` | the catalog's CardBorder in light | -| `app/card-fill-dark` | `#26231E` (`brand.ink/800`) | `Keyboard.appCardFillDark` | the catalog's CardFill in dark | -| `app/card-fill-light` | `#F5F3EB` (`brand.paper/tint`) | `Keyboard.appCardFillLight` | the catalog's CardFill in light | -| `app/cta-dark` | `#67AD82` (`brand.green/400`) | `Keyboard.appCtaDark` | the catalog's CTA in dark: the site's button green | -| `app/cta-light` | `#01762F` (`brand.green/700`) | `Keyboard.appCtaLight` | the catalog's CTA in light: the one green button | -| `app/cta-text-dark` | `#1D1B16` (`brand.ink`) | `Keyboard.appCtaTextDark` | the catalog's CTAText in dark: ink on green, as the site | -| `app/cta-text-light` | `#FFFFFF` (`brand.white`) | `Keyboard.appCtaTextLight` | the catalog's CTAText in light | -| `app/muted-dark` | `#A5A4A2` (`brand.neutral/500`) | `Keyboard.appMutedDark` | the catalog's Muted in dark | -| `app/muted-light` | `#777673` (`brand.neutral/700`) | `Keyboard.appMutedLight` | the catalog's Muted in light: eyebrows, captions, secondary text | -| `app/page-dark` | `#1D1B16` (`brand.ink`) | `Keyboard.appPageDark` | the catalog's Page in dark: ink, the website's ground | -| `app/page-light` | `#FDFCF8` (`brand.paper/page`) | `Keyboard.appPageLight` | the catalog's Page in light: the design system's page | -| `app/text-dark` | `#F5F3EB` (`brand.paper/tint`) | `Keyboard.appTextDark` | the catalog's Text in dark | -| `app/text-light` | `#1D1B16` (`brand.ink`) | `Keyboard.appTextLight` | the catalog's Text in light | -| `kb/cancel` | `#E67F36` (`brand.orange`) | `Keyboard.kbCancel` | the panel's cancel × | -| `kb/field` | `#33302A` (`themes.dark/field`) | `Keyboard.kbField` | the key-term field | -| `kb/field-border` | `#3A362F` (`themes.dark/field-border`) | `Keyboard.kbFieldBorder` | the field's hairline | -| `kb/full-access-note` | `#E67F36` (`brand.orange`) | `Keyboard.kbFullAccessNote` | the one line of words the keyboard ever shows | -| `kb/key` | `#33302A` (`themes.dark/key`) | `Keyboard.kbKey` | an ordinary key | -| `kb/key-modifier` | `#26231E` (`themes.dark/key-modifier`) | `Keyboard.kbKeyModifier` | shift, delete, globe, return, 123, cancel | -| `kb/legend` | `#F5F3EB` (`themes.dark/legend`) | `Keyboard.kbLegend` | key legends and bare glyphs | -| `kb/legend-secondary` | `#A5A4A2` (`themes.dark/legend-secondary`) | `Keyboard.kbLegendSecondary` | the mono word labels (123, ABC, return, space) and the + at rest | -| `kb/notice-error` | `#E67F36` (`brand.orange`) | `Keyboard.kbNoticeError` | the solid ring and glyph for an error | -| `kb/popup` | `#33302A` (`themes.dark/popup`) | `Keyboard.kbPopup` | the letter pop-up | -| `kb/signal` | `#67AD82` (`themes.dark/signal`) | `Keyboard.kbSignal` | the wave, the caret, the saved check | -| `kb/surface` | `#1D1B16` (`themes.dark/surface`) | `Keyboard.kbSurface` | the design face's surface (Swift reads the palette) | +| Token | Value | Swift | Use | +| ----------------------- | ------------------------------------------ | ----------------------------- | --------------------------------------------------------------------------------------------- | +| `app/accent-dark` | `#67AD82` (`brand.green/400`) | `Keyboard.appAccentDark` | the catalog's AccentColor in dark | +| `app/accent-light` | `#01762F` (`brand.green/700`) | `Keyboard.appAccentLight` | the catalog's AccentColor in light: Blurt green, the one accent every screen and the CTA take | +| `app/card-border-dark` | `#3A362F` (`brand.ink/600`) | `Keyboard.appCardBorderDark` | the catalog's CardBorder in dark | +| `app/card-border-light` | `#C7C3B2` (`brand.paper/400`) | `Keyboard.appCardBorderLight` | the catalog's CardBorder in light | +| `app/card-fill-dark` | `#26231E` (`brand.ink/800`) | `Keyboard.appCardFillDark` | the catalog's CardFill in dark | +| `app/card-fill-light` | `#F5F3EB` (`brand.paper/tint`) | `Keyboard.appCardFillLight` | the catalog's CardFill in light | +| `app/cta-dark` | `#67AD82` (`keyboard.app/accent-dark`) | `Keyboard.appCtaDark` | the catalog's CTA in dark: the one accent button | +| `app/cta-light` | `#01762F` (`keyboard.app/accent-light`) | `Keyboard.appCtaLight` | the catalog's CTA in light: the one accent button | +| `app/cta-text-dark` | `#1D1B16` (`brand.ink`) | `Keyboard.appCtaTextDark` | the catalog's CTAText in dark: ink on green, as the site | +| `app/cta-text-light` | `#FFFFFF` (`brand.white`) | `Keyboard.appCtaTextLight` | the catalog's CTAText in light | +| `app/muted-dark` | `#A5A4A2` (`brand.neutral/500`) | `Keyboard.appMutedDark` | the catalog's Muted in dark | +| `app/muted-light` | `#777673` (`brand.neutral/700`) | `Keyboard.appMutedLight` | the catalog's Muted in light: eyebrows, captions, secondary text | +| `app/page-dark` | `#1D1B16` (`brand.ink`) | `Keyboard.appPageDark` | the catalog's Page in dark: ink, the website's ground | +| `app/page-light` | `#FDFCF8` (`brand.paper/page`) | `Keyboard.appPageLight` | the catalog's Page in light: the design system's page | +| `app/text-dark` | `#F5F3EB` (`brand.paper/tint`) | `Keyboard.appTextDark` | the catalog's Text in dark | +| `app/text-light` | `#1D1B16` (`brand.ink`) | `Keyboard.appTextLight` | the catalog's Text in light | +| `kb/cancel` | `#E67F36` (`brand.orange`) | `Keyboard.kbCancel` | the panel's cancel × | +| `kb/field` | `#33302A` (`themes.dark/field`) | `Keyboard.kbField` | the key-term field | +| `kb/field-border` | `#3A362F` (`themes.dark/field-border`) | `Keyboard.kbFieldBorder` | the field's hairline | +| `kb/full-access-note` | `#E67F36` (`brand.orange`) | `Keyboard.kbFullAccessNote` | the one line of words the keyboard ever shows | +| `kb/key` | `#33302A` (`themes.dark/key`) | `Keyboard.kbKey` | an ordinary key | +| `kb/key-modifier` | `#26231E` (`themes.dark/key-modifier`) | `Keyboard.kbKeyModifier` | shift, delete, globe, return, 123, cancel | +| `kb/legend` | `#F5F3EB` (`themes.dark/legend`) | `Keyboard.kbLegend` | key legends and bare glyphs | +| `kb/legend-secondary` | `#A5A4A2` (`themes.dark/legend-secondary`) | `Keyboard.kbLegendSecondary` | the mono word labels (123, ABC, return, space) and the + at rest | +| `kb/notice-error` | `#E67F36` (`brand.orange`) | `Keyboard.kbNoticeError` | the solid ring and glyph for an error | +| `kb/popup` | `#33302A` (`themes.dark/popup`) | `Keyboard.kbPopup` | the letter pop-up | +| `kb/signal` | `#67AD82` (`themes.dark/signal`) | `Keyboard.kbSignal` | the wave, the caret, the saved check | +| `kb/surface` | `#1D1B16` (`themes.dark/surface`) | `Keyboard.kbSurface` | the design face's surface (Swift reads the palette) | diff --git a/App/BlurtiOS/Design/tokens.json b/App/BlurtiOS/Design/tokens.json index 29e2f484..51d6772e 100644 --- a/App/BlurtiOS/Design/tokens.json +++ b/App/BlurtiOS/Design/tokens.json @@ -215,7 +215,7 @@ }, "app/accent-light": { "value": "{brand.green/700}", - "use": "the catalog's AccentColor in light" + "use": "the catalog's AccentColor in light: Blurt green, the one accent every screen and the CTA take" }, "app/accent-dark": { "value": "{brand.green/400}", @@ -274,12 +274,12 @@ "use": "the catalog's Muted in dark" }, "app/cta-light": { - "value": "{brand.green/700}", - "use": "the catalog's CTA in light: the one green button" + "value": "{keyboard.app/accent-light}", + "use": "the catalog's CTA in light: the one accent button" }, "app/cta-dark": { - "value": "{brand.green/400}", - "use": "the catalog's CTA in dark: the site's button green" + "value": "{keyboard.app/accent-dark}", + "use": "the catalog's CTA in dark: the one accent button" }, "app/cta-text-light": { "value": "{brand.white}", diff --git a/App/BlurtiOS/README.md b/App/BlurtiOS/README.md index b24bc878..844697cd 100644 --- a/App/BlurtiOS/README.md +++ b/App/BlurtiOS/README.md @@ -63,8 +63,9 @@ The iPhone code splits the way the Mac's does. **`BlurtiOSCore/`** is the logic contract, the keyboard's model and rules, the voice state, term packs, the relay injector — and plays the part `BlurtEngine` plays for the Mac shell: no SwiftUI, no views, no audio (the keyboard links it, and the keyboard never hears anything; `check-invariants.sh` enforces the last). The -app (`BlurtiOS/Sources`) and the keyboard (`BlurtKeyboard/Sources`) are the UI on top of it, with -`Shared/` holding what both draw with (design tokens, type, brand colours). +app (`BlurtiOS/Sources`) and the keyboard (`BlurtKeyboard/Sources`) are the UI on top of it, drawn +with **`BlurtDesign/`**, the component library: the design tokens, type roles and brand colours, +and the pieces every screen is built from (card, page, form, eyebrow, button, wordmark, grain). It is a static framework, so the app and the keyboard each link their own copy beside the engine's, and its API is `package` (one `SWIFT_PACKAGE_NAME` across the project), not `public`: diff --git a/App/BlurtiOS/Shared/BlurtBrand.swift b/App/BlurtiOS/Shared/BlurtBrand.swift deleted file mode 100644 index 99da6498..00000000 --- a/App/BlurtiOS/Shared/BlurtBrand.swift +++ /dev/null @@ -1,20 +0,0 @@ -/// Blurt's brand, as the Mac app defines it (`App/Blurt/Blurt/Branding/BlurtBrand.swift`, -/// from the 2026-08-31 comps), plus the handful of shades the keyboard needs -/// for a surface the Mac never draws. Compiled into the app and the keyboard. -/// -/// The values live in `Design/tokens.json` and arrive here through the -/// generated `DesignTokens`; these are the Mac's names for them, kept so the -/// call sites still read in the Mac's vocabulary. Add a colour to the tokens, -/// not here. -/// -/// The keyboard's surface is fixed per theme, the way the Mac's overlay pill is -/// fixed: it floats over whatever app the user is typing in, so it cannot take -/// its cue from that app's appearance, and it must read the same on a white -/// Notes page and a black Messages thread. Everything on it therefore names -/// the raw shades — `greenOnDark`, `ink` — and never `accent`. -enum BlurtBrand { - /// `#01762F` — the wordmark green, for light chrome. - nonisolated static let green = DesignTokens.Brand.green700 - /// `#E67F36` — the error word. The body stays ink; the word carries the alarm. - nonisolated static let errorOrange = DesignTokens.Brand.orange -} diff --git a/App/BlurtiOS/project.yml b/App/BlurtiOS/project.yml index bca32eac..a0245ef4 100644 --- a/App/BlurtiOS/project.yml +++ b/App/BlurtiOS/project.yml @@ -70,13 +70,31 @@ targets: # The AssemblyAI SDK library (the key validator's types live there). - package: BlurtEngine product: AssemblyAI + # The component library: the generated design tokens, the type roles, the + # brand colours, and the pieces every screen is built from (card, page, form, + # eyebrow, button, wordmark, film grain). SwiftUI only — no BlurtiOSCore, no + # engine — so it reads as the design system and nothing else. iPhone only for + # now: the Mac app keeps its own Branding/. Static and extension-safe for the + # reasons BlurtiOSCore is: the keyboard links it too. + BlurtDesign: + type: framework.static + platform: iOS + sources: + - path: BlurtDesign + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.assemblyai.blurt.ios.design + GENERATE_INFOPLIST_FILE: YES + SWIFT_TREAT_WARNINGS_AS_ERRORS: YES + SWIFT_DEFAULT_ACTOR_ISOLATION: MainActor + APPLICATION_EXTENSION_API_ONLY: YES BlurtiOS: type: application platform: iOS sources: - path: BlurtiOS/Sources - # The Mac's brand colours (accent, card fill and border), copied verbatim - # from App/Blurt/Blurt/Assets.xcassets — JSON can't reference JSON. + # The app's colour sets, generated from Design/tokens.json: the + # system-drawn accent and the launch screen's page colour read them. - path: BlurtiOS/Assets.xcassets # Apple's required-reason declarations: App Group UserDefaults (1C8F.1) # and the bundle's own (CA92.1), the only such API either target uses. @@ -87,10 +105,6 @@ targets: - path: ../../Design/brand/AppIcon.icon - path: ../../Design/brand/blurt-ready-logo.png buildPhase: resources - # The UI both the app and the keyboard draw with (design tokens, type, - # brand colours), compiled into each — an extension can't import the - # app's module. The App Group contract itself is BlurtiOSCore. - - path: Shared # The keyboard's views and model: the theme picker draws the real keyboard # on its cards, the home screen draws the orb and the meter, and debug # builds render every layout and state (KeyboardGalleryView) for design @@ -165,6 +179,8 @@ targets: product: BlurtEngine - target: BlurtiOSCore embed: false + - target: BlurtDesign + embed: false - target: BlurtKeyboard # The keyboard. It types, it starts and stops a dictation, it inserts the # words; it never hears anything. `RequestsOpenAccess` asks the user for Full @@ -174,7 +190,6 @@ targets: platform: iOS sources: - path: BlurtKeyboard/Sources - - path: Shared - path: BlurtKeyboard/PrivacyInfo.xcprivacy buildPhase: resources settings: @@ -206,10 +221,13 @@ targets: product: BlurtEngine - target: BlurtiOSCore embed: false + - target: BlurtDesign + embed: false # Unit tests for the iPhone code — BlurtiOSCore's logic, and what the app and # keyboard build on it — hosted by the app so `@testable import BlurtiOS` # reaches the shell too (the keyboard's sources are compiled into the app). - # Core is linked into the host already, so the bundle only imports it. + # Core and BlurtDesign are linked into the host already, so the bundle only + # imports them. # Swift Testing; `scripts/ios-test.sh` runs them on a simulator. BlurtiOSTests: type: bundle.unit-test diff --git a/scripts/check-invariants.sh b/scripts/check-invariants.sh index b070fc5d..a181de55 100755 --- a/scripts/check-invariants.sh +++ b/scripts/check-invariants.sh @@ -80,11 +80,11 @@ ENGINE="Sources/*.swift" # next one. APP="App/*.swift App/*.yml App/*.plist :!App/Blurt/Blurt.xcodeproj :!App/BlurtiOS/BlurtiOSTests" TESTS="Tests/*.swift App/Blurt/BlurtUITests/*.swift App/BlurtiOS/BlurtiOSTests/*.swift" -# The keyboard extension and what it compiles or links — its own sources, the -# Shared UI, and BlurtiOSCore: iOS lets no keyboard use the microphone, and the +# The keyboard extension and what it compiles or links — its own sources, +# BlurtDesign, and BlurtiOSCore: iOS lets no keyboard use the microphone, and the # design keeps every audio type out of it, so an import there is a decision # being reversed by accident. -KEYBOARD="App/BlurtiOS/BlurtKeyboard/*.swift App/BlurtiOS/Shared/*.swift App/BlurtiOS/BlurtiOSCore/*.swift" +KEYBOARD="App/BlurtiOS/BlurtKeyboard/*.swift App/BlurtiOS/BlurtDesign/*.swift App/BlurtiOS/BlurtiOSCore/*.swift" # The streaming-STT rule's one carve-out: the experimental read-selection-aloud # feature speaks through AssemblyAI's streaming TTS, which is WebSocket-only. # The rule is about *dictation* staying one request/one response, so the TTS diff --git a/scripts/check.sh b/scripts/check.sh index 9f877c97..1a7b57c7 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -429,7 +429,7 @@ check_invariants() { run_check "settled decisions (AGENTS.md invariants)" check_invariants # The design's one source (App/BlurtiOS/Design/tokens.json) and everything -# generated from it — Shared/DesignTokens.swift, DESIGN.md's token tables, the +# generated from it — BlurtDesign/DesignTokens.swift, DESIGN.md's token tables, the # app's three colour sets — must agree, and the keyboard's views must carry no # design literal that belongs in the tokens. scripts/design-sync.sh --check # regenerates into .build/ and diffs, the same shape as the xcodegen drift diff --git a/scripts/design-sync.sh b/scripts/design-sync.sh index cd4c0bcf..fa0f0e50 100755 --- a/scripts/design-sync.sh +++ b/scripts/design-sync.sh @@ -36,7 +36,7 @@ else fi OUTPUTS=( - App/BlurtiOS/Shared/DesignTokens.swift + App/BlurtiOS/BlurtDesign/DesignTokens.swift App/BlurtiOS/DESIGN.md App/BlurtiOS/BlurtiOS/Assets.xcassets/AccentColor.colorset/Contents.json App/BlurtiOS/BlurtiOS/Assets.xcassets/CardFill.colorset/Contents.json @@ -55,17 +55,17 @@ OUTPUTS=( format_outputs() { local root="$1" if [ "${#SWIFT_FORMAT[@]}" -gt 0 ]; then - "${SWIFT_FORMAT[@]}" format --in-place "$root/App/BlurtiOS/Shared/DesignTokens.swift" + "${SWIFT_FORMAT[@]}" format --in-place "$root/App/BlurtiOS/BlurtDesign/DesignTokens.swift" fi if command -v prettier >/dev/null 2>&1; then prettier --log-level warn --ignore-path /dev/null --write "$root/App/BlurtiOS/DESIGN.md" >/dev/null fi } -# The design-literal lint. Scope: every file under the keyboard's and the -# app's sources that draws — new views are linted by default — minus the few +# The design-literal lint. Scope: every file under the keyboard's, the app's +# and BlurtDesign's sources that draws — new views are linted by default — minus the few # that compute rather than draw (the host controller, the palette, the -# geometry) and Grain.swift, whose body is the grain's recipe (density, +# geometry) and FilmGrain.swift, whose body is the grain's recipe (density, # the two shades), with only its opacity as a token. Numbers 0 and 1 pass: `Spacer(minLength: 0)`, # `.opacity(on ? 1 : 0)` and `scaleEffect(1 + …)` are structure, not design. lint_literals() { @@ -74,7 +74,7 @@ import glob, os, re, sys # BlurtiOSCore (the model, the voice state, the interaction rules) is not # globbed at all: it draws nothing, so it has no design literals to carry. exempt = { - "KeyboardViewController.swift", "KeyboardPalette.swift", "KeyGeometry.swift", "MotionHeld.swift", "Grain.swift", + "KeyboardViewController.swift", "KeyboardPalette.swift", "KeyGeometry.swift", "MotionHeld.swift", "FilmGrain.swift", } app_exempt = { "BlurtiOSApp.swift", "DictationCoordinator.swift", "ListeningWindow.swift", "ListeningSource.swift", @@ -84,6 +84,9 @@ files = sorted( [path for path in glob.glob("App/BlurtiOS/BlurtKeyboard/Sources/**/*.swift", recursive=True) if os.path.basename(path) not in exempt] + [path for path in glob.glob("App/BlurtiOS/BlurtiOS/Sources/*.swift") if os.path.basename(path) not in app_exempt] + # The component library draws too; its generated tokens are the literals' home. + + [path for path in glob.glob("App/BlurtiOS/BlurtDesign/*.swift") + if os.path.basename(path) not in exempt | {"DesignTokens.swift"}] ) modifiers = re.compile( r"\.(padding|frame|font|blur|scaleEffect|offset|shadow|opacity|cornerRadius|saturation|brightness" diff --git a/scripts/design-tokens.swift b/scripts/design-tokens.swift index b0109256..a20d3460 100755 --- a/scripts/design-tokens.swift +++ b/scripts/design-tokens.swift @@ -1,5 +1,4 @@ -#!/usr/bin/env swift // The design's one source of numbers and colours, fanned out: reads // App/BlurtiOS/Design/tokens.json (hand-edited: it is the // source, not an export) and writes everything that used to repeat those values by // hand — `App/BlurtiOS/Shared/DesignTokens.swift`, the generated tables in // `App/BlurtiOS/DESIGN.md`, and the app's three asset-catalog colour sets. Run -// through scripts/design-sync.sh, which also formats the output and, with +#!/usr/bin/env swift // The design's one source of numbers and colours, fanned out: reads // App/BlurtiOS/Design/tokens.json (hand-edited: it is the // source, not an export) and writes everything that used to repeat those values by // hand — `App/BlurtiOS/BlurtDesign/DesignTokens.swift`, the generated tables in // `App/BlurtiOS/DESIGN.md`, and the app's three asset-catalog colour sets. Run // through scripts/design-sync.sh, which also formats the output and, with // --check, fails on drift the way check.sh fails on a stale .pbxproj. // // swift scripts/design-tokens.swift [--root ] @@ -34,7 +33,7 @@ while !arguments.isEmpty { } let tokensPath = "App/BlurtiOS/Design/tokens.json" -let swiftPath = "App/BlurtiOS/Shared/DesignTokens.swift" +let swiftPath = "App/BlurtiOS/BlurtDesign/DesignTokens.swift" let designPath = "App/BlurtiOS/DESIGN.md" let catalogPath = "App/BlurtiOS/BlurtiOS/Assets.xcassets" @@ -243,25 +242,26 @@ var swift = """ /// (`Design/tokens.json`): what the views draw with, what DESIGN.md's tables /// say, and what the asset catalog holds, all from the same file. Swift names /// follow the token names by one rule (`kb/key-modifier` → `kbKeyModifier`). - enum DesignTokens { + /// `package`: BlurtDesign's, read by the app, the keyboard and the tests. + package enum DesignTokens { """ for group in groups { - swift += " enum \(typeName(group)) {\n" + swift += " package enum \(typeName(group)) {\n" for token in tokens where token.group == group { let (type, literal) = swiftLiteral(token) let use = token.use.map { $0 } ?? (token.value.isAlias ? "`\(aliasTarget(token))`" : nil) swift += docComment(use, indent: " ") - swift += " nonisolated static let \(token.swiftName): \(type) = \(literal)\n" + swift += " package nonisolated static let \(token.swiftName): \(type) = \(literal)\n" } swift += " }\n\n" } -swift += " enum Gradients {\n" +swift += " package enum Gradients {\n" for gradient in gradients { swift += docComment(gradient.use, indent: " ") - swift += " nonisolated static let \(camel(gradient.name)) = LinearGradient(\n stops: [\n" + swift += " package nonisolated static let \(camel(gradient.name)) = LinearGradient(\n stops: [\n" for stop in gradient.stops { swift += " .init(color: \(swiftColorReference(stop.color)), location: \(format(stop.location))),\n" } @@ -272,7 +272,7 @@ swift += " }\n\n" swift += """ /// Every token as `group.name: value`, the way tokens.json renders it, so a /// test can pin the compiled values to the file (`DesignTokensTests`). - nonisolated static let manifest: [String: String] = [ + package nonisolated static let manifest: [String: String] = [ """ for token in tokens { diff --git a/scripts/ios-typecheck.sh b/scripts/ios-typecheck.sh index e4134c8d..860bd738 100755 --- a/scripts/ios-typecheck.sh +++ b/scripts/ios-typecheck.sh @@ -59,16 +59,22 @@ echo "BlurtiOSCore" xcrun swiftc "${IOS_FLAGS[@]}" -application-extension -D DEBUG -module-name BlurtiOSCore \ -emit-module -emit-module-path "$WORK/BlurtiOSCore.swiftmodule" "$IOS"/BlurtiOSCore/*.swift +# The component library both targets link, emitted the same way and just as +# extension-safe. +echo "BlurtDesign" +xcrun swiftc "${IOS_FLAGS[@]}" -application-extension -module-name BlurtDesign \ + -emit-module -emit-module-path "$WORK/BlurtDesign.swiftmodule" "$IOS"/BlurtDesign/*.swift + # The app also compiles the keyboard's sources, all but its entry point # (project.yml: the theme picker and the home screen draw the real keyboard # and orb), and CI builds Debug, so `#if DEBUG` code is checked too. echo "BlurtiOS" find "$IOS/BlurtKeyboard/Sources" -name '*.swift' ! -name KeyboardViewController.swift -print0 \ | xargs -0 xcrun swiftc "${TARGET_FLAGS[@]}" -D DEBUG -module-name BlurtiOS \ - "$IOS"/BlurtiOS/Sources/*.swift "$IOS"/Shared/*.swift + "$IOS"/BlurtiOS/Sources/*.swift echo "BlurtKeyboard" xcrun swiftc "${TARGET_FLAGS[@]}" -application-extension -module-name BlurtKeyboard \ - "$IOS"/BlurtKeyboard/Sources/*.swift "$IOS"/Shared/*.swift + "$IOS"/BlurtKeyboard/Sources/*.swift echo "ios-typecheck: ok" From 86736186b31f2082a7f4413c4913614e7ec09809 Mon Sep 17 00:00:00 2001 From: James He Date: Wed, 14 Oct 2026 19:15:56 -0500 Subject: [PATCH 7/9] refactor(sdk): move key terms and text shortcuts into the AssemblyAI SDK (#231) * refactor(sdk): move key terms and text shortcuts into the AssemblyAI SDK KeyTerms (parse a comma-separated list, fit it to keyterms_prompt's caps; was KeytermsBoost plus KeyTermsStore.parse) and TextShortcut with its on-device TextShortcutExpander now live in Sources/AssemblyAI, so any app on the SDK gets them. BlurtEngine keeps Blurt's storage: KeyTermsStore and TextShortcutStore, their UserDefaults keys and limits. The iPhone's duplicate KeyTermList is replaced by KeyTerms. The Mac app only gains an import; its behaviour and stored data are unchanged. Co-Authored-By: Claude Opus 5.5 * style(sdk): keep KeyTerms.join's doc comment on its declaration Co-Authored-By: Claude Opus 5.5 * feat(ios): add a Words tab for key terms and text shortcuts (#232) * feat(ios): add a Words tab for key terms and text shortcuts The app gets Dictate and Words tabs under a brand tab bar, as in the blurt-ios prototype. MainView owns what works on either tab: Settings from each tab's gear, the consent sheet and shared key-term lists. Words, on the AssemblyAI SDK's KeyTerms and TextShortcut: - Key terms as pills with an inline add field (commas add several, duplicates drop, capped at the request's 100), the contact-name count, and Share. They move here from Settings. - Text shortcuts with an inline "When I say / Type this" form; re-adding a phrase replaces it. Expanded on the phone, never sent. BlurtDesign gains BrandTabBar, FlowLayout, brandInput() and brandFootnote(), and brand buttons fade while disabled. Co-Authored-By: Claude Opus 5.5 * fix(ios): drop the Words tab pointer from Settings Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .claude/agents/cleanup-reviewer.md | 2 +- .claude/skills/project-guardrails/SKILL.md | 2 +- AGENTS.md | 30 ++- .../Blurt/Wizard/Steps/KeyTermsStepView.swift | 2 +- .../Blurt/Wizard/TextShortcutsSection.swift | 7 +- App/BlurtiOS/BlurtDesign/BrandTabBar.swift | 52 ++++ App/BlurtiOS/BlurtDesign/Chrome.swift | 33 +++ App/BlurtiOS/BlurtDesign/DesignTokens.swift | 35 ++- App/BlurtiOS/BlurtDesign/FlowLayout.swift | 52 ++++ .../BlurtiOS/Sources/BlurtiOSApp.swift | 3 +- .../Sources/DictationCoordinator.swift | 2 +- App/BlurtiOS/BlurtiOS/Sources/HomeView.swift | 29 +- App/BlurtiOS/BlurtiOS/Sources/MainView.swift | 67 +++++ .../BlurtiOS/Sources/SettingsView.swift | 24 +- App/BlurtiOS/BlurtiOS/Sources/WordsView.swift | 247 ++++++++++++++++++ App/BlurtiOS/BlurtiOSCore/KeyTermList.swift | 21 -- App/BlurtiOS/BlurtiOSCore/SharedState.swift | 5 +- App/BlurtiOS/BlurtiOSCore/TermPack.swift | 7 +- App/BlurtiOS/BlurtiOSCore/WordsEdit.swift | 34 +++ .../BlurtiOSTests/SharedStateTests.swift | 20 -- .../BlurtiOSTests/WordsEditTests.swift | 35 +++ App/BlurtiOS/DESIGN.md | 34 ++- App/BlurtiOS/Design/tokens.json | 46 +++- App/BlurtiOS/README.md | 2 +- README.md | 3 +- .../Dictation/DictationWireTypes.swift | 2 +- .../Dictation/KeyTerms.swift} | 86 +++--- .../Dictation/TextShortcuts.swift} | 52 +++- Sources/AssemblyAI/README.md | 6 +- .../BlurtEngine/Audio/SoundPackCatalog.swift | 2 +- .../BlurtEngine/Config/APIKeySubmission.swift | 1 + .../BlurtEngine/Config/KeyTermsStore.swift | 30 +-- .../Config/TextShortcutStore.swift | 40 +-- .../BlurtEngine/Pipeline/DictationLog.swift | 5 +- .../Pipeline/DictationSession+Pipeline.swift | 1 + .../Pipeline/DictationSession.swift | 6 +- Sources/BlurtEngine/README.md | 6 +- .../STT/AssemblyAITranscriber.swift | 2 +- Sources/BlurtEngine/STT/STTPrompt.swift | 8 +- .../STT/TranscriptionContext.swift | 4 +- .../BlurtEngineTests/DictationLogTests.swift | 3 +- .../BlurtEngineTests/KeyTermsStoreTests.swift | 30 --- ...msBoostTests.swift => KeyTermsTests.swift} | 78 ++++-- .../BlurtEngineTests/KeytermsWireTests.swift | 2 +- .../PersistedSettingsTests.swift | 1 + Tests/BlurtEngineTests/STTPromptTests.swift | 2 +- .../Stubs/DictationSessionTestSupport.swift | 1 + .../BlurtEngineTests/TextShortcutTests.swift | 3 +- .../TranscriptionContextTests.swift | 3 +- scripts/mutate.sh | 3 +- 50 files changed, 867 insertions(+), 304 deletions(-) create mode 100644 App/BlurtiOS/BlurtDesign/BrandTabBar.swift create mode 100644 App/BlurtiOS/BlurtDesign/FlowLayout.swift create mode 100644 App/BlurtiOS/BlurtiOS/Sources/MainView.swift create mode 100644 App/BlurtiOS/BlurtiOS/Sources/WordsView.swift delete mode 100644 App/BlurtiOS/BlurtiOSCore/KeyTermList.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/WordsEdit.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift rename Sources/{BlurtEngine/STT/KeytermsBoost.swift => AssemblyAI/Dictation/KeyTerms.swift} (52%) rename Sources/{BlurtEngine/Pipeline/TextShortcutExpander.swift => AssemblyAI/Dictation/TextShortcuts.swift} (75%) rename Tests/BlurtEngineTests/{KeytermsBoostTests.swift => KeyTermsTests.swift} (54%) diff --git a/.claude/agents/cleanup-reviewer.md b/.claude/agents/cleanup-reviewer.md index ca5a0440..371cbc23 100644 --- a/.claude/agents/cleanup-reviewer.md +++ b/.claude/agents/cleanup-reviewer.md @@ -39,7 +39,7 @@ spacing, the injector's window identity, the developer-mode log. `appName`, `windowTitle`, `fieldLabel` and `selectedText` are not unused just because `STTPrompt` ignores them. Nor is `targetIsSecure`, which no request carries: it is what stops a password dictated into a secure field being remembered as history. The -key-terms read is not unused either: it feeds `KeytermsBoost`, the request's separate +key-terms read is not unused either: it feeds `KeyTerms`, the request's separate `config.keyterms_prompt` list. Do not propose deleting any of them, folding the key terms back into the context as a `Keywords:` clause, or dropping the `context:` parameter. diff --git a/.claude/skills/project-guardrails/SKILL.md b/.claude/skills/project-guardrails/SKILL.md index 1b8bcd69..1dda9752 100644 --- a/.claude/skills/project-guardrails/SKILL.md +++ b/.claude/skills/project-guardrails/SKILL.md @@ -90,7 +90,7 @@ only one of stt_prompt or prompt; they are the same field`, before the audio is read. Adding it alongside is not a compatibility shim, it is every dictation failing. - **Key terms are keyterms prompting, not context text.** They ride - `config.keyterms_prompt` as a flat array of strings (`KeytermsBoost`), fitted to + `config.keyterms_prompt` as a flat array of strings (`KeyTerms`), fitted to that field's own 2048-character cap _and_ its 100-term `maxItems` — two caps on one field, and the count is reachable under the byte budget. Don't fold them back into the context as a `Keywords: a, b, c.` clause, and don't also send `keyterms` or `word_boost` — diff --git a/AGENTS.md b/AGENTS.md index 5f66c3f7..68db7a16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,8 @@ Four reflexes before you touch anything: The package's second library, **`AssemblyAI`** (`Sources/AssemblyAI/`), is the AssemblyAI API client the engine is built on: the `HTTPTransport` seam and its streamed upload body, the dictation wire types and -multipart framing, and `APIKeyValidator`. It holds no Blurt policy (no prompts, styles, key storage or +multipart framing, `APIKeyValidator`, and dictation vocabulary — `KeyTerms` (parsing a list and fitting +it to `keyterms_prompt`'s caps) and `TextShortcut` with its on-device `TextShortcutExpander`. It holds no Blurt policy (no prompts, styles, key storage or `User-Agent`; `APIKeyValidator.blurt()` is where Blurt's agent joins it), so it can become a standalone Swift SDK. It is part of the same local package, so the dependency-free rule is unchanged. @@ -58,8 +59,9 @@ workflow and the _why_ behind the design; the engine's README covers the _what_ ```text Sources/AssemblyAI/ the AssemblyAI API client library (README.md there): HTTPTransport + - ChunkedRequestBody, Dictation/ (wire types, multipart framing), - APIKeyValidator. No Blurt policy; BlurtEngine depends on it + ChunkedRequestBody, Dictation/ (wire types, multipart framing, + KeyTerms, TextShortcuts), APIKeyValidator. No Blurt policy; + BlurtEngine depends on it Sources/BlurtEngine/ the engine (dependency-free Swift package) README.md the engine's developer guide (quick start, seams, error table) Audio/ MicCapture (+meter) over CaptureSessionRecorder (the @@ -85,7 +87,7 @@ Sources/BlurtEngine/ the engine (dependency-free Swift package) PermissionsChecker (mic + Accessibility), PermissionsReset, SigningIdentity (macOS only) Pipeline/ DictationSession (actor) + phases, UI projections, geometry, log - STT/ AssemblyAITranscriber, Conversation/TranscriptionContext, KeytermsBoost, + STT/ AssemblyAITranscriber, Conversation/TranscriptionContext, CleanupInstruction, SyncSTTLimits TTS/ experimental read-aloud: SelectionSpeaker, AssemblyAISpeechSynthesizer (the one WebSocket), SpeechSegmenter, StreamingPCMPlayer + @@ -108,7 +110,7 @@ App/BlurtiOS/ the iPhone app and its keyboard (README.md there is SwiftUI, no audio; `package` access; coverage-gated (ios-test.sh) BlurtiOS/Sources/ the app: DictationCoordinator (composition root), ListeningWindow + WindowedAudioSource (the always-on capture, since iOS can't open - the mic from the background), HomeView and the other screens + the mic from the background), MainView (Dictate and Words tabs) and the screens BlurtKeyboard/Sources/ the keyboard's views: a remote control that inserts the words; three layouts (slim bar / panel / full keyboard) over BlurtiOSCore's model BlurtDesign/ the component library both targets link: the generated DesignTokens, @@ -420,7 +422,7 @@ rule along with the row. | Pin transcription to English, or set a language at all | Hurt non-English transcription; language is left to the model's own detection. **No `config.language_codes`** either — note the plural, which is the documented spelling, though the undocumented singular `language_code` is accepted too (a 200, not the unknown-key 400 a bogus name earns), so both are live and both are pinned absent. Detection was measured to work with neither it nor a prompt set (es/fr/de/ja clips each transcribed in their own language against the live endpoint, rewrite included), and re-measured 2026-09-11: a Spanish clip came back as Spanish with no field, with `language_codes: ["es"]`, **and** with `language_codes: ["en"]` — asking for English did not force English, so the reference's `["en"]` default does not describe this route and omission is not a bet on a default. Setting one would only take working detection away. `KeytermsWireTests` asserts the absence of both spellings. | | Send `config.prompt` alongside `config.stt_prompt` | They are the same field under two names — the docs give `stt_prompt` as the field and note it is "Also accepted as `prompt`" — and a request carrying both is rejected before the audio is read (`provide only one of stt_prompt or prompt; they are the same field`). So this is never a compatible addition: it is a 400 on _every_ dictation. `stt_prompt` is the documented spelling and the only one to send; `prompt` is the legacy one, and the route's own inner validator still answers under it (see `STTPrompt.characterCap`), which is a reason to treat it as deprecated rather than interchangeable. | | Bring back `config.conversation_context` | Replaced by `config.stt_prompt` (`STTPrompt`), which carries the same two signals as one string. Both fields still work and can ride the same request, so re-adding the turn list sends the prior text twice rather than failing. `prompt` is `stt_prompt`'s own alias — a request carrying both is a 400 (`they are the same field`) — and a custom prompt replaces the service's managed default, which is the trade this swap accepted. | -| Widen the request's context past history + the prior chunk | `STTPrompt.text` reads exactly two fields of `TranscriptionContext` — `recentTranscripts` and `priorText`. The app name, window title, field label and selected text are captured for the paste path and the developer-mode log and stay on the machine; the app/window/field hints and the `Selected text:` block were removed, not gated. Don't add one back, and don't route that context onto the request by another path. The user's key terms are the exception that proves the rule: they _are_ sent, as the request's own `keyterms_prompt` list (`KeytermsBoost`) — never folded back into the prompt. | +| Widen the request's context past history + the prior chunk | `STTPrompt.text` reads exactly two fields of `TranscriptionContext` — `recentTranscripts` and `priorText`. The app name, window title, field label and selected text are captured for the paste path and the developer-mode log and stay on the machine; the app/window/field hints and the `Selected text:` block were removed, not gated. Don't add one back, and don't route that context onto the request by another path. The user's key terms are the exception that proves the rule: they _are_ sent, as the request's own `keyterms_prompt` list (`KeyTerms`) — never folded back into the prompt. | | Add a "remove filler words (um, uh, like)" clause | Not in the STT model's trained instruction set — a no-op, deliberately dropped; disfluency removal is the server-side LLM rewrite's job. | | Add a keystroke-typing paste path or a length threshold | Injection is **always** clipboard paste (save → write → ⌘V → settle → restore), with the copied-to-clipboard degradation when the target is lost. | | Add `LSUIElement` or a menu-bar-**only** mode | Blurt is a Dock app first. The `MenuBarExtra` status item is convenience layered on the Dock icon; the notch can hide a status item, so nothing may depend on it. A menu-bar-only variant was reverted twice. | @@ -695,9 +697,9 @@ and the instruction cap was measured against the live endpoint before the refere (it now gives `llm_instruction` a `maxLength` of 2048, in characters, while the constant counts UTF-8 bytes — the conservative unit). There is a _third_ cap in the same request — `config.keyterms_prompt` accepts 2048 characters summed across all terms -(`KeytermsBoost.characterCap`), the same figure as the instruction cap but a limit on a different +(`KeyTerms.characterCap`), the same figure as the instruction cap but a limit on a different field — and that field carries a _fourth_, which is a **count** and not a size: -at most 100 terms (`KeytermsBoost.termCap`, the reference's `maxItems`). The count is the one the +at most 100 terms (`KeyTerms.termCap`, the reference's `maxItems`). The count is the one the byte budget does not imply, and it is reachable, since 150 short terms cost ~1500 bytes and clear the byte cap easily. Four caps, four constants: never reuse one field's figure for another, and never assume one cap on a field is the only cap on it. @@ -954,14 +956,14 @@ as trimming an over-cap list, which made our 4096 a bandwidth measure; `stt_prom what stands between a long history and every dictation failing. Its unit is **Unicode scalars**, measured rather than inferred from the word "characters": 4096 `é` (8192 UTF-8 bytes) is accepted, while 820 family emoji — 820 grapheme clusters but 4100 scalars — is rejected. So Swift's -`String.count` would under-count and let a 400 through, and the UTF-8 bytes `KeytermsBoost` and +`String.count` would under-count and let a 400 through, and the UTF-8 bytes `KeyTerms` and `CleanupInstruction` count (their own servers' units being unmeasured) would needlessly halve the budget for accented text. Two omissions are deliberate and regression-tested — no language directive and no filler-word clause; see [Settled decisions](#settled-decisions--dont-reintroduce-these). **The key terms are the request's other steering field.** -`Sources/BlurtEngine/STT/KeytermsBoost.swift` sends the user's Settings list as +`Sources/AssemblyAI/Dictation/KeyTerms.swift` sends the user's Settings list as `config.keyterms_prompt` — keyterms prompting: a flat array of strings biasing recognition toward those exact spellings. It is a sibling of `stt_prompt`, not an alternative; the API takes both on the same request and @@ -975,8 +977,8 @@ both halves of that, since `/v1/transcribe/live` rejects an unknown key outright (`Extra inputs are not permitted`) and `keyterms_prompt` returns 200. Send exactly one of the three, never two — they are mutually exclusive on this route (`provide only one of keyterms_prompt, keyterms, or word_boost`, 400 before the audio is read), so a rename here is a swap and never an -addition. `KeytermsBoost.fitted` takes whole terms in the user's order while -they fit the field's own 2048-character cap (`KeytermsBoost.characterCap`, measured in UTF-8 bytes — +addition. `KeyTerms.fitted` takes whole terms in the user's order while +they fit the field's own 2048-character cap (`KeyTerms.characterCap`, measured in UTF-8 bytes — the conservative reading, as with `CleanupInstruction`) **and** the list stays within `termCap` (100), the reference's `maxItems` on the same field — two independent limits, so both are checked; a comma-split list of 150 short terms clears the byte cap and 400s on the count. It returns a plain `[String]`, not an @@ -984,7 +986,7 @@ optional one — the repo bans optional collections, and "no terms" needs only o omit-vs-`[]` distinction that _does_ matter to the API lives in `DictationConfig.encode(to:)`, which drops the key for an empty list. Terms arrive already trimmed and deduped from `KeyTermsStore.parse`, so length is the only thing enforced here. Unit-tested in -`Tests/BlurtEngineTests/KeytermsBoostTests.swift`. +`Tests/BlurtEngineTests/KeyTermsTests.swift`. **The two fields are deliberately different JSON types, and neither tolerates the other's.** `stt_prompt` is a string — an array earns `400 stt_prompt: Input should be a valid string` — while @@ -1014,7 +1016,7 @@ Engine-side stores, all `UserDefaults`-backed value types with the same shape: - **`TriggerKeyStore`** (`BlurtTriggerKeyCode`), **`SoundPackStore`** (`BlurtSoundPack`), **`KeyTermsStore`** (`BlurtKeyTerms`, the user's domain vocabulary, re-read at every press via the session's `keyTermsProvider` and sent as the request's `keyterms_prompt` list — - see `KeytermsBoost`), + see `KeyTerms`), **`DeveloperModeStore`** (`BlurtDeveloperMode`, off by default), **`EnhancedTranscriptsStore`** (`BlurtEnhancedTranscripts`, **on** by default — unset reads as enabled; picks the cleanup rewrite over the verbatim transcript in the dictation _response_, diff --git a/App/Blurt/Blurt/Wizard/Steps/KeyTermsStepView.swift b/App/Blurt/Blurt/Wizard/Steps/KeyTermsStepView.swift index e11aa128..d9b65879 100644 --- a/App/Blurt/Blurt/Wizard/Steps/KeyTermsStepView.swift +++ b/App/Blurt/Blurt/Wizard/Steps/KeyTermsStepView.swift @@ -4,7 +4,7 @@ import SwiftUI /// The "Key Terms" section of the Settings window: a free-text /// area where the user lists comma-separated domain words (names, jargon, product /// names). These are sent as every dictation request's word-boost list -/// (see `KeyTermsStore` / `KeytermsBoost`), so the model favors those exact +/// (see `KeyTermsStore` / `KeyTerms`), so the model favors those exact /// spellings. Optional — it never gates setup; an empty list just sends no terms, /// which omits the field. struct KeyTermsStepView: View { diff --git a/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift b/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift index 8e799170..6a19e9c4 100644 --- a/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift +++ b/App/Blurt/Blurt/Wizard/TextShortcutsSection.swift @@ -1,3 +1,4 @@ +import AssemblyAI import BlurtEngine import SwiftUI @@ -143,14 +144,14 @@ private struct TextShortcutEditorSheet: View { self.shortcut = shortcut isExisting = shortcuts.contains { $0.id == shortcut.id } otherKeys = Set( - shortcuts.filter { $0.id != shortcut.id }.map { TextShortcutStore.matchKey(for: $0.trigger) }) + shortcuts.filter { $0.id != shortcut.id }.map { TextShortcut.matchKey(for: $0.trigger) }) _trigger = State(initialValue: shortcut.trigger) _expansion = State(initialValue: shortcut.expansion) } - /// The phrase as the matcher sees it (`TextShortcutStore.matchKey`) — empty + /// The phrase as the matcher sees it (`TextShortcut.matchKey`) — empty /// when it has no letters or digits. - private var triggerKey: String { TextShortcutStore.matchKey(for: trigger) } + private var triggerKey: String { TextShortcut.matchKey(for: trigger) } /// A phrase the matcher can't tell from another shortcut's — "personal /// email" vs "Personal-Email" — would be dropped by the store's dedupe, so diff --git a/App/BlurtiOS/BlurtDesign/BrandTabBar.swift b/App/BlurtiOS/BlurtDesign/BrandTabBar.swift new file mode 100644 index 00000000..5d619dce --- /dev/null +++ b/App/BlurtiOS/BlurtDesign/BrandTabBar.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// The brand's tab bar, in place of the system one: each tab's icon over its +/// mono uppercase label, the selected tab in the accent and the rest muted, a +/// hairline above. The tabs are the caller's; this only draws and selects. +package struct BrandTabBar: View { + let tabs: [Tab] + @Binding var selection: Tab + let label: (Tab) -> String + /// An SF Symbol; the selected tab draws its `.fill` variant. + let symbol: (Tab) -> String + + package init( + _ tabs: [Tab], selection: Binding, label: @escaping (Tab) -> String, symbol: @escaping (Tab) -> String + ) { + self.tabs = tabs + _selection = selection + self.label = label + self.symbol = symbol + } + + package var body: some View { + HStack(spacing: 0) { + ForEach(tabs) { tab in + let isSelected = tab == selection + Button { + selection = tab + } label: { + VStack(spacing: DesignTokens.Metrics.appTabGap) { + Image(systemName: isSelected ? symbol(tab) + ".fill" : symbol(tab)) + .font(.system(size: DesignTokens.Typography.sizeTabIcon, weight: DesignTokens.Typography.weightGlyph)) + Text(label(tab).uppercased()) + .font(BlurtType.mono(DesignTokens.Typography.sizeTabLabel, weight: .medium)) + .tracking(DesignTokens.Typography.trackingEyebrow) + } + .foregroundStyle(isSelected ? BlurtBrand.accent : BlurtBrand.muted) + .frame(maxWidth: .infinity, minHeight: DesignTokens.Metrics.appTabHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label(tab)) + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + } + .padding(.top, DesignTokens.Metrics.appLineGap) + .background(BlurtBrand.page) + .overlay(alignment: .top) { + Rectangle().fill(BlurtBrand.cardBorder).frame(height: DesignTokens.Metrics.cardBorder) + } + .animation(.easeInOut(duration: DesignTokens.Motion.colour), value: selection) + } +} diff --git a/App/BlurtiOS/BlurtDesign/Chrome.swift b/App/BlurtiOS/BlurtDesign/Chrome.swift index b9d9584b..5c82212c 100644 --- a/App/BlurtiOS/BlurtDesign/Chrome.swift +++ b/App/BlurtiOS/BlurtDesign/Chrome.swift @@ -90,10 +90,43 @@ package struct BrandButtonStyle: ButtonStyle { role == .secondary ? BlurtBrand.cardBorder : Color.clear, lineWidth: DesignTokens.Metrics.cardBorder) ) .opacity(configuration.isPressed ? DesignTokens.Metrics.opacityPressed : 1) + .modifier(FadedWhenDisabled()) .animation(.easeInOut(duration: DesignTokens.Motion.colour), value: configuration.isPressed) } } +/// A brand button that can't be pressed yet sits back rather than looking live. +/// A modifier because a `ButtonStyle` can't read the environment itself. +private struct FadedWhenDisabled: ViewModifier { + @Environment(\.isEnabled) private var isEnabled + + func body(content: Content) -> some View { + content.opacity(isEnabled ? 1 : DesignTokens.Metrics.opacityDisabled) + } +} + +extension View { + /// The brand's input: the page colour inside the card, 8 pt corners, the + /// card's hairline, the body face. + package func brandInput() -> some View { + font(BlurtType.body(DesignTokens.Typography.sizeBody)) + .foregroundStyle(BlurtBrand.text) + .padding(.horizontal, DesignTokens.Metrics.appChipPadX) + .padding(.vertical, DesignTokens.Metrics.appInputPadY) + .background(BlurtBrand.page, in: RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput)) + .overlay( + RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput) + .strokeBorder(BlurtBrand.cardBorder, lineWidth: DesignTokens.Metrics.cardBorder)) + } + + /// The brand's small print under a card: the caption size, muted. + package func brandFootnote() -> some View { + font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.muted) + .fixedSize(horizontal: false, vertical: true) + } +} + /// The lowercase wordmark from the Mac's ready screen, tinted with the accent. /// The image (`blurt-ready-logo.png`, shared from `Design/brand`) is the host /// bundle's resource, not the library's; without it the word is set in the diff --git a/App/BlurtiOS/BlurtDesign/DesignTokens.swift b/App/BlurtiOS/BlurtDesign/DesignTokens.swift index 1bde0c19..bae82a97 100644 --- a/App/BlurtiOS/BlurtDesign/DesignTokens.swift +++ b/App/BlurtiOS/BlurtDesign/DesignTokens.swift @@ -229,18 +229,32 @@ package enum DesignTokens { package nonisolated static let appHeroPad: CGFloat = 20 /// The header's gear, the copy glyph. package nonisolated static let appIcon: CGFloat = 20 + /// An app input's top and bottom (its sides are app/chip-pad-x). + package nonisolated static let appInputPadY: CGFloat = 10 /// Between lines of one thought. package nonisolated static let appLineGap: CGFloat = 6 /// The screens' side padding. package nonisolated static let appPagePad: CGFloat = 20 + /// A key-term pill's top and bottom, and its side beside the ×. + package nonisolated static let appPillPadY: CGFloat = 4 + /// The × on a key-term pill: its tap square. + package nonisolated static let appPillRemove: CGFloat = 24 /// Between the theme card's two faces. package nonisolated static let appPreviewGap: CGFloat = 8 + /// A list row's top and bottom inside a card. + package nonisolated static let appRowPadY: CGFloat = 8 + /// The × on a text-shortcut row: its tap square. + package nonisolated static let appRowRemove: CGFloat = 32 /// Between the home screen's sections. package nonisolated static let appSectionGap: CGFloat = 24 /// The setup steps' mono numerals. package nonisolated static let appSetupNumberWidth: CGFloat = 24 /// Between a section's eyebrow and its content, and between cards. package nonisolated static let appStackGap: CGFloat = 12 + /// Between a tab's icon and its label. + package nonisolated static let appTabGap: CGFloat = 4 + /// A tab in the brand tab bar: its icon over its mono label. + package nonisolated static let appTabHeight: CGFloat = 52 /// The card's hairline. package nonisolated static let cardBorder: CGFloat = 1 /// The app's cards (radius/card). @@ -307,7 +321,7 @@ package enum DesignTokens { package nonisolated static let marginSide: CGFloat = 6.5 /// The keyboard's top, and the slim bar's bottom. package nonisolated static let marginVertical: CGFloat = 8 - /// The + without Full Access. + /// The + without Full Access; a brand button that can't be pressed yet. package nonisolated static let opacityDisabled: CGFloat = 0.4 /// The cross glint when the words land. package nonisolated static let opacityGlint: CGFloat = 0.95 @@ -439,8 +453,16 @@ package enum DesignTokens { package nonisolated static let sizeLegend: CGFloat = 16 /// Letter keys: the iPhone's (estimated from the l glyph, apple-geometry.json). package nonisolated static let sizeLetter: CGFloat = 24 + /// The × on a key-term pill. + package nonisolated static let sizePillGlyph: CGFloat = 10 /// The letter pop-up. package nonisolated static let sizePopup: CGFloat = 32 + /// The × on a text-shortcut row. + package nonisolated static let sizeRemoveGlyph: CGFloat = 12 + /// A tab's icon. + package nonisolated static let sizeTabIcon: CGFloat = 18 + /// A tab's mono label. + package nonisolated static let sizeTabLabel: CGFloat = 11 /// The key-term field. package nonisolated static let sizeTerm: CGFloat = 17 /// The app's serif headline. @@ -605,12 +627,19 @@ package enum DesignTokens { "metrics.app/hero-gap": "14", "metrics.app/hero-pad": "20", "metrics.app/icon": "20", + "metrics.app/input-pad-y": "10", "metrics.app/line-gap": "6", "metrics.app/page-pad": "20", + "metrics.app/pill-pad-y": "4", + "metrics.app/pill-remove": "24", "metrics.app/preview-gap": "8", + "metrics.app/row-pad-y": "8", + "metrics.app/row-remove": "32", "metrics.app/section-gap": "24", "metrics.app/setup-number-width": "24", "metrics.app/stack-gap": "12", + "metrics.app/tab-gap": "4", + "metrics.app/tab-height": "52", "metrics.card/border": "1", "metrics.card/radius": "12", "metrics.caret/height": "20", @@ -718,7 +747,11 @@ package enum DesignTokens { "type.size/label": "12", "type.size/legend": "16", "type.size/letter": "24", + "type.size/pill-glyph": "10", "type.size/popup": "32", + "type.size/remove-glyph": "12", + "type.size/tab-icon": "18", + "type.size/tab-label": "11", "type.size/term": "17", "type.size/title": "34", "type.tracking/cta": "1.4", diff --git a/App/BlurtiOS/BlurtDesign/FlowLayout.swift b/App/BlurtiOS/BlurtDesign/FlowLayout.swift new file mode 100644 index 00000000..0a1ecb76 --- /dev/null +++ b/App/BlurtiOS/BlurtDesign/FlowLayout.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// Lays its children out left to right, wrapping onto a new line when the next +/// one won't fit — the key-term pills. +package struct FlowLayout: Layout { + var spacing: CGFloat + + package init(spacing: CGFloat = DesignTokens.Metrics.appChipGap) { self.spacing = spacing } + + package func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let rows = rows(for: subviews, width: proposal.width ?? .infinity) + let height = rows.map(\.height).reduce(0, +) + spacing * CGFloat(max(rows.count - 1, 0)) + return CGSize(width: proposal.width ?? rows.map(\.width).max() ?? 0, height: height) + } + + package func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + var y = bounds.minY + for row in rows(for: subviews, width: bounds.width) { + var x = bounds.minX + for index in row.indices { + let size = subviews[index].sizeThatFits(.unspecified) + subviews[index].place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size)) + x += size.width + spacing + } + y += row.height + spacing + } + } + + private struct Row { + var indices: [Int] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + private func rows(for subviews: Subviews, width: CGFloat) -> [Row] { + var rows: [Row] = [] + var row = Row() + for index in subviews.indices { + let size = subviews[index].sizeThatFits(.unspecified) + let needed = row.indices.isEmpty ? size.width : row.width + spacing + size.width + if needed > width, !row.indices.isEmpty { + rows.append(row) + row = Row() + } + row.width = row.indices.isEmpty ? size.width : row.width + spacing + size.width + row.height = max(row.height, size.height) + row.indices.append(index) + } + if !row.indices.isEmpty { rows.append(row) } + return rows + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index a24e3de0..9eb84874 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -60,8 +60,7 @@ struct BlurtiOSApp: App { } private var home: some View { - HomeView(coordinator: coordinator) - .onOpenURL { coordinator.handle($0) } + MainView(coordinator: coordinator) .task { await start() } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index c6d7b1bd..bf0598d8 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -204,7 +204,7 @@ final class DictationCoordinator { /// The user's own key terms (from the App Group, where the keyboard adds /// them on the spot), then the contact names the keyboard read off the - /// phone, deduplicated case-insensitively. `KeytermsBoost` fits the list + /// phone, deduplicated case-insensitively. `KeyTerms` fits the list /// to the request's caps (100 terms, 2048 bytes), first entries first, which /// is why the typed terms lead. nonisolated static func keyTerms() -> [String] { diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index 168af9ff..d6abf538 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -4,18 +4,19 @@ import BlurtEngine import BlurtiOSCore import SwiftUI -/// The app's one screen: the Blurt landing page on a phone. The wordmark and +/// The Dictate tab: the Blurt landing page on a phone. The wordmark and /// the gear across the top; the status as an eyebrow, a serif headline and a /// line of body text over the voice element; the one green button; then the /// styles and the recent dictations under their eyebrows. Setup sits on top /// while anything is missing; everything adjustable lives behind the gear. struct HomeView: View { var coordinator: DictationCoordinator + /// Opens Settings, which `MainView` presents over either tab. + let showSettings: () -> Void @Environment(\.scenePhase) private var scenePhase @State private var microphoneGranted = AVAudioApplication.shared.recordPermission == .granted @State private var keyboardSeen = SharedStore.keyboardEverSeen @State private var showsKeyEntry = false - @State private var showsSettings = false /// When the last dictation's words landed — the hero's glint. @State private var landedAt: Date? /// TEMPORARY: the mic concept from Settings, so the hero shows the one @@ -52,21 +53,6 @@ struct HomeView: View { .environment(\.voiceElementKind, VoiceElementKind(rawValue: voiceRaw) ?? .shipped) .toolbar(.hidden, for: .navigationBar) .sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) } - .sheet(isPresented: $showsSettings) { SettingsView(coordinator: coordinator) } - .sheet(isPresented: Binding(get: { coordinator.needsConsent }, set: { coordinator.needsConsent = $0 })) { - ConsentView { Task { await coordinator.grantConsent() } } - } - .sheet(item: Binding(get: { coordinator.pendingTermPack }, set: { coordinator.pendingTermPack = $0 })) { - ImportTermsView(pack: $0) - } - .alert( - "Couldn't read that list", - isPresented: Binding(get: { coordinator.termPackUnreadable }, set: { coordinator.termPackUnreadable = $0 }) - ) { - Button("OK") {} - } message: { - Text("It isn't a Blurt key-term list, or it's too big.") - } .onChange(of: scenePhase) { _, phase in if phase == .active { refreshStatus() } } @@ -82,14 +68,7 @@ struct HomeView: View { HStack { Wordmark() Spacer() - Button { - showsSettings = true - } label: { - Image(systemName: "gearshape") - .font(.system(size: DesignTokens.Metrics.appIcon, weight: DesignTokens.Typography.weightGlyph)) - .foregroundStyle(BlurtBrand.muted) - } - .accessibilityLabel("Settings") + SettingsButton(action: showSettings) } .frame(height: DesignTokens.Metrics.appHeaderHeight) } diff --git a/App/BlurtiOS/BlurtiOS/Sources/MainView.swift b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift new file mode 100644 index 00000000..ac615bf4 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift @@ -0,0 +1,67 @@ +import BlurtDesign +import BlurtiOSCore +import SwiftUI + +/// The app: the Dictate and Words tabs under the brand tab bar, plus what has +/// to work whichever tab is showing — Settings from either tab's gear, the +/// consent prompt the keyboard's `blurt://start` can raise, and a shared +/// key-term list arriving from Messages. +struct MainView: View { + enum Tab: CaseIterable, Identifiable { + case dictate, words + + var id: Self { self } + + var label: String { + switch self { + case .dictate: "Dictate" + case .words: "Words" + } + } + + var symbol: String { + switch self { + case .dictate: "mic" + case .words: "character.book.closed" + } + } + } + + var coordinator: DictationCoordinator + @State private var tab = Tab.dictate + @State private var showsSettings = false + + var body: some View { + Group { + switch tab { + case .dictate: HomeView(coordinator: coordinator) { showsSettings = true } + case .words: WordsView(coordinator: coordinator) { showsSettings = true } + } + } + .safeAreaInset(edge: .bottom, spacing: 0) { + BrandTabBar(Tab.allCases, selection: $tab, label: \.label, symbol: \.symbol) + } + .sheet(isPresented: $showsSettings) { SettingsView(coordinator: coordinator) } + .sheet(isPresented: Binding(get: { coordinator.needsConsent }, set: { coordinator.needsConsent = $0 })) { + ConsentView { Task { await coordinator.grantConsent() } } + } + .sheet(item: Binding(get: { coordinator.pendingTermPack }, set: { coordinator.pendingTermPack = $0 })) { + ImportTermsView(pack: $0) + } + .alert( + "Couldn't read that list", + isPresented: Binding(get: { coordinator.termPackUnreadable }, set: { coordinator.termPackUnreadable = $0 }) + ) { + Button("OK") {} + } message: { + Text("It isn't a Blurt key-term list, or it's too big.") + } + // The keyboard's `blurt://start` is about the listening window, which the + // Dictate tab shows; a key-term list lands on Words, where it's added. + .onOpenURL { url in + tab = TermPack.looksLikePack(url) ? .words : .dictate + coordinator.handle(url) + } + .tint(BlurtBrand.accent) + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 3b45b100..3e19377e 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -6,7 +6,7 @@ import SwiftUI /// Everything adjustable, grouped the way the Mac's Settings are: the keyboard /// (layout, theme, hands-free), listening, transcription, the account, and /// about — a form on the brand's page, its sections under mono eyebrows. -/// Presented from the home screen's gear. +/// Presented from either tab's gear. struct SettingsView: View { var coordinator: DictationCoordinator @Environment(\.dismiss) private var dismiss @@ -19,7 +19,6 @@ struct SettingsView: View { @AppStorage(BlurtShared.Key.voiceElement, store: SharedStore.defaults) private var voiceRaw = VoiceElementKind.shipped.rawValue @State private var showsKeyEntry = false - @AppStorage(SharedStore.keyTermsKey, store: SharedStore.defaults) private var keyTerms = "" @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue @@ -104,24 +103,10 @@ struct SettingsView: View { Section { Toggle("Enhanced transcripts", isOn: $enhancedTranscripts) NavigationLink("Output styles") { StylesView() } - TextField("Key terms, comma-separated", text: $keyTerms, axis: .vertical) - .autocorrectionDisabled() - LabeledContent("Key terms", value: "\(KeyTermList.parse(keyTerms).count)") - if !KeyTermList.parse(keyTerms).isEmpty { - ShareLink( - item: termPack, subject: Text("Blurt key terms"), message: Text(termPack.plainText), - preview: SharePreview(termPack.name, icon: Image(systemName: "text.badge.plus")) - ) { - Label("Share key terms…", systemImage: "square.and.arrow.up") - } - } } header: { Eyebrow("Transcription") } footer: { - Text( - "Enhanced transcripts clean up punctuation and wording. Key terms are names and jargon to spell right — " - + "add one from the keyboard with the + beside the orb, share the list with a group chat so everyone's " - + "dictation gets the names right; \(coordinator.lexiconNameCount) contact names come along automatically.") + Text("Enhanced transcripts clean up punctuation and wording.") } } @@ -145,11 +130,6 @@ struct SettingsView: View { } } - /// The whole list, for a friend: a `.blurtterms` file plus the words as text. - private var termPack: TermPack { - TermPack(name: "Key terms", from: nil, terms: KeyTermList.parse(keyTerms)) - } - private static var version: String { let short = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" diff --git a/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift b/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift new file mode 100644 index 00000000..3545648c --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/WordsView.swift @@ -0,0 +1,247 @@ +import AssemblyAI +import BlurtDesign +import BlurtEngine +import BlurtiOSCore +import SwiftUI + +/// The Words tab: key terms the speech model should spell exactly, and text +/// shortcuts that turn a spoken phrase into longer text. Both apply to every +/// dictation, from the app and from the keyboard, as on Blurt for Mac. +struct WordsView: View { + var coordinator: DictationCoordinator + let showSettings: () -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { + HStack { + Text("Words") + .font(BlurtType.heading(DesignTokens.Typography.sizeTitle)) + .foregroundStyle(BlurtBrand.text) + .accessibilityAddTraits(.isHeader) + Spacer() + SettingsButton(action: showSettings) + } + .frame(height: DesignTokens.Metrics.appHeaderHeight) + KeyTermsCard(contactNames: coordinator.lexiconNameCount) + TextShortcutsCard() + } + .padding(.horizontal, DesignTokens.Metrics.appPagePad) + .padding(.bottom, DesignTokens.Metrics.appSectionGap) + } + .scrollDismissesKeyboard(.interactively) + .page() + } +} + +/// The gear in each tab's header, which opens Settings. +struct SettingsButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "gearshape") + .font(.system(size: DesignTokens.Metrics.appIcon, weight: DesignTokens.Typography.weightGlyph)) + .foregroundStyle(BlurtBrand.muted) + } + .accessibilityLabel("Settings") + } +} + +/// The key terms as the design system's pills, a field to add more, and the +/// list's share link. They live in the App Group, where the keyboard's + adds +/// one on the spot, so this reads and writes the shared slot. +private struct KeyTermsCard: View { + let contactNames: Int + @AppStorage(SharedStore.keyTermsKey, store: SharedStore.defaults) private var raw = "" + @State private var draft = "" + + private var terms: [String] { KeyTerms.parse(raw) } + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { + Eyebrow("Key terms") + if !terms.isEmpty { + FlowLayout { + ForEach(terms, id: \.self) { term in + TermPill(term: term) { SharedStore.keyTerms = terms.filter { $0 != term } } + } + } + } + HStack(spacing: DesignTokens.Metrics.appChipGap) { + TextField("Add a name or product term", text: $draft) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.done) + .onSubmit(add) + .brandInput() + Button("Add", action: add) + .buttonStyle(BrandButtonStyle()) + .fixedSize() + .disabled(KeyTerms.parse(draft).isEmpty || terms.count >= KeyTerms.termCap) + } + Text( + "Spelled exactly as written in every dictation. Separate several with commas, or add one from the " + + "keyboard with the + beside the mic. \(terms.count) of \(KeyTerms.termCap)." + + (contactNames > 0 ? " Your \(contactNames) contact names come along automatically." : "") + ) + .brandFootnote() + if !terms.isEmpty { + ShareLink( + item: pack, subject: Text("Blurt key terms"), message: Text(pack.plainText), + preview: SharePreview(pack.name, icon: Image(systemName: "text.badge.plus")) + ) { + Label("Share key terms", systemImage: "square.and.arrow.up") + .font(BlurtType.body(DesignTokens.Typography.sizeCaption, bold: true)) + } + .tint(BlurtBrand.accent) + } + } + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + } + + /// The whole list, for a friend: a `.blurtterms` file plus the words as text. + private var pack: TermPack { TermPack(name: "Key terms", from: nil, terms: terms) } + + private func add() { + let updated = WordsEdit.addingKeyTerms(draft, to: terms) + guard updated != terms else { return } + SharedStore.keyTerms = updated + draft = "" + } +} + +/// A key term as the design system's pill: full radius, the page's fill inside +/// the card, a hairline, the body face. Its × removes it. +private struct TermPill: View { + let term: String + let remove: () -> Void + + var body: some View { + HStack(spacing: DesignTokens.Metrics.appLineGap) { + Text(term) + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.text) + Button(action: remove) { + Image(systemName: "xmark") + .font(.system(size: DesignTokens.Typography.sizePillGlyph, weight: .bold)) + .foregroundStyle(BlurtBrand.muted) + .frame(width: DesignTokens.Metrics.appPillRemove, height: DesignTokens.Metrics.appPillRemove) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove \(term)") + } + .padding(.leading, DesignTokens.Metrics.appChipPadX) + .padding(.trailing, DesignTokens.Metrics.appPillPadY) + .padding(.vertical, DesignTokens.Metrics.appPillPadY) + .background(BlurtBrand.page, in: Capsule()) + .overlay(Capsule().strokeBorder(BlurtBrand.cardBorder, lineWidth: DesignTokens.Metrics.cardBorder)) + } +} + +/// The text shortcuts, each with its ×, over two fields that add one. The +/// session expands them on the phone (`TextShortcutExpander`), so nothing here +/// goes to AssemblyAI. The store owns the JSON: this binds the raw slot only to +/// redraw, and writes through `TextShortcutStore`. +private struct TextShortcutsCard: View { + @AppStorage(TextShortcutStore.defaultsKey) private var raw = "" + @State private var trigger = "" + @State private var expansion = "" + @FocusState private var focus: Field? + private let store = TextShortcutStore() + + private enum Field { case trigger, expansion } + + private var shortcuts: [TextShortcut] { store.shortcuts(decoding: raw) } + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { + Eyebrow("Text shortcuts") + if !shortcuts.isEmpty { + VStack(spacing: 0) { + ForEach(Array(shortcuts.enumerated()), id: \.element.id) { index, shortcut in + if index > 0 { Rectangle().fill(BlurtBrand.cardBorder).frame(height: DesignTokens.Metrics.cardBorder) } + ShortcutRow(shortcut: shortcut) { store.shortcuts = shortcuts.filter { $0.id != shortcut.id } } + } + } + } + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appChipGap) { + TextField("When I say… (my email)", text: $trigger) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.next) + .focused($focus, equals: .trigger) + .onSubmit { focus = .expansion } + .onChange(of: trigger) { _, value in + if value.count > TextShortcutStore.triggerLimit { + trigger = String(value.prefix(TextShortcutStore.triggerLimit)) + } + } + .brandInput() + TextField("Type this (me@example.com)", text: $expansion, axis: .vertical) + .lineLimit(1...4) // literal-ok: the field's rows + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focus, equals: .expansion) + .onChange(of: expansion) { _, value in + if value.count > TextShortcutStore.expansionLimit { + expansion = String(value.prefix(TextShortcutStore.expansionLimit)) + } + } + .brandInput() + Button("Add shortcut", action: add) + .buttonStyle(BrandButtonStyle()) + .disabled(!canAdd) + } + Text("Say the phrase anywhere in a dictation and Blurt types the full text instead. They stay on this phone.") + .brandFootnote() + } + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + } + + private var canAdd: Bool { + WordsEdit.canAddShortcut(trigger: trigger, expansion: expansion) + && shortcuts.count < TextShortcutStore.shortcutLimit + } + + private func add() { + guard canAdd else { return } + store.shortcuts = WordsEdit.addingShortcut(trigger: trigger, expansion: expansion, to: shortcuts) + trigger = "" + expansion = "" + focus = nil + } +} + +private struct ShortcutRow: View { + let shortcut: TextShortcut + let remove: () -> Void + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Metrics.appStackGap) { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) { + Eyebrow(shortcut.trigger, color: BlurtBrand.accent) + Text(shortcut.expansion) + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.text) + .lineLimit(2) // literal-ok: two lines of the replacement + } + Spacer(minLength: 0) + Button(action: remove) { + Image(systemName: "xmark") + .font(.system(size: DesignTokens.Typography.sizeRemoveGlyph, weight: .semibold)) + .foregroundStyle(BlurtBrand.muted) + .frame(width: DesignTokens.Metrics.appRowRemove, height: DesignTokens.Metrics.appRowRemove) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove \(shortcut.trigger)") + } + .padding(.vertical, DesignTokens.Metrics.appRowPadY) + } +} diff --git a/App/BlurtiOS/BlurtiOSCore/KeyTermList.swift b/App/BlurtiOS/BlurtiOSCore/KeyTermList.swift deleted file mode 100644 index cc212d1a..00000000 --- a/App/BlurtiOS/BlurtiOSCore/KeyTermList.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Foundation - -/// The comma-separated key-term list, as the engine's `KeyTermsStore` reads -/// it: split, trimmed, emptied of blanks, deduplicated case-insensitively in -/// first-seen order. -package nonisolated enum KeyTermList { - package static func parse(_ raw: String) -> [String] { - var seen = Set() - var terms: [String] = [] - for piece in raw.split(separator: ",") { - let term = piece.trimmingCharacters(in: .whitespacesAndNewlines) - guard !term.isEmpty, seen.insert(term.lowercased()).inserted else { continue } - terms.append(term) - } - return terms - } - - package static func join(_ terms: [String]) -> String { - terms.joined(separator: ", ") - } -} diff --git a/App/BlurtiOS/BlurtiOSCore/SharedState.swift b/App/BlurtiOS/BlurtiOSCore/SharedState.swift index f6246837..667d94e7 100644 --- a/App/BlurtiOS/BlurtiOSCore/SharedState.swift +++ b/App/BlurtiOS/BlurtiOSCore/SharedState.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation // MARK: - The App Group contract @@ -336,8 +337,8 @@ package nonisolated enum SharedStore { /// `BlurtKeyTerms`, comma-separated) but in the App Group, so the keyboard /// can add one on the spot and the app reads it on the very next press. package static var keyTerms: [String] { - get { KeyTermList.parse(defaults.string(forKey: keyTermsKey) ?? "") } - set { defaults.set(KeyTermList.join(newValue), forKey: keyTermsKey) } + get { KeyTerms.parse(defaults.string(forKey: keyTermsKey) ?? "") } + set { defaults.set(KeyTerms.join(newValue), forKey: keyTermsKey) } } /// `BlurtKeyTerms` — spelled out here rather than imported from the engine so diff --git a/App/BlurtiOS/BlurtiOSCore/TermPack.swift b/App/BlurtiOS/BlurtiOSCore/TermPack.swift index a5633e2a..f5dc3b43 100644 --- a/App/BlurtiOS/BlurtiOSCore/TermPack.swift +++ b/App/BlurtiOS/BlurtiOSCore/TermPack.swift @@ -1,3 +1,4 @@ +import AssemblyAI import CoreTransferable import Foundation import UniformTypeIdentifiers @@ -56,7 +57,7 @@ package nonisolated struct TermPack: Codable, Identifiable, Transferable { /// without Blurt still gets the words. package var plainText: String { let lead = from.map { "\($0)'s Blurt key terms" } ?? "Blurt key terms" - return "\(lead) (\(name)): \(KeyTermList.join(terms))" + return "\(lead) (\(name)): \(KeyTerms.join(terms))" } /// Whether a URL is meant to be a pack at all — a `.blurtterms` file or a @@ -82,13 +83,13 @@ package nonisolated struct TermPack: Codable, Identifiable, Transferable { guard let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil } let value = { (name: String) in items.first { $0.name == name }?.value } return TermPack( - name: value("name") ?? "Shared key terms", from: value("from"), terms: KeyTermList.parse(value("add") ?? "") + name: value("name") ?? "Shared key terms", from: value("from"), terms: KeyTerms.parse(value("add") ?? "") ).capped() } /// The pack within the caps, or nil when nothing usable is left. private func capped() -> TermPack? { - let kept = KeyTermList.parse(KeyTermList.join(terms)).filter { $0.count <= Self.lengthCap } + let kept = KeyTerms.parse(KeyTerms.join(terms)).filter { $0.count <= Self.lengthCap } let terms = Array(kept.prefix(Self.termCap)) guard !terms.isEmpty else { return nil } return TermPack( diff --git a/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift b/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift new file mode 100644 index 00000000..1f1d403c --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/WordsEdit.swift @@ -0,0 +1,34 @@ +import AssemblyAI +import Foundation + +/// The Words tab's edits, apart from its views: what adding a key term or a +/// text shortcut does to the list it joins. The lists' own rules — parsing and +/// the request's caps (`KeyTerms`), a shortcut's match key (`TextShortcut`) — +/// are the AssemblyAI SDK's; the stores' limits are the engine's. +package nonisolated enum WordsEdit { + /// `terms` with whatever `draft` holds added at the end: one term, or several + /// separated by commas. Duplicates of a term already there drop + /// case-insensitively, and the list stops at the request's term cap, so a + /// term that couldn't be sent isn't kept either. + package static func addingKeyTerms(_ draft: String, to terms: [String]) -> [String] { + Array(KeyTerms.parse(KeyTerms.join(terms + KeyTerms.parse(draft))).prefix(KeyTerms.termCap)) + } + + /// Whether a shortcut from these fields could be saved: a phrase with a + /// letter or digit in it, and a replacement that isn't blank. + package static func canAddShortcut(trigger: String, expansion: String) -> Bool { + !TextShortcut.matchKey(for: trigger).isEmpty + && !expansion.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// `shortcuts` with a new one at the top. A phrase already there — by match + /// key, so "my email" and "My-Email" are one — gets the new replacement + /// rather than a second entry the expander could never apply. + package static func addingShortcut(trigger: String, expansion: String, to shortcuts: [TextShortcut]) + -> [TextShortcut] + { + let key = TextShortcut.matchKey(for: trigger) + return [TextShortcut(trigger: trigger, expansion: expansion)] + + shortcuts.filter { TextShortcut.matchKey(for: $0.trigger) != key } + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift b/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift index 8a68bc7a..2d7d7c38 100644 --- a/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift +++ b/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift @@ -40,26 +40,6 @@ struct MicAlignmentSettingTests { } } -@Suite("Key term list") -struct KeyTermListTests { - @Test("parse trims, drops blanks, and deduplicates case-insensitively in first-seen order") - func parse() { - #expect(KeyTermList.parse(" Rizz , skibidi,,RIZZ, Blurt ,") == ["Rizz", "skibidi", "Blurt"]) - } - - @Test("join is what parse reads back") - func roundTrip() { - let terms = ["Neil Bisht", "AssemblyAI", "rizz"] - #expect(KeyTermList.parse(KeyTermList.join(terms)) == terms) - } - - @Test("an empty or all-blank list is empty") - func empty() { - #expect(KeyTermList.parse("").isEmpty) - #expect(KeyTermList.parse(" , ,").isEmpty) - } -} - @Suite("Phase snapshot staleness") struct PhaseSnapshotTests { private func snapshot(_ state: PhaseSnapshot.State, ageSeconds: TimeInterval) -> PhaseSnapshot { diff --git a/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift b/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift new file mode 100644 index 00000000..95a290d3 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/WordsEditTests.swift @@ -0,0 +1,35 @@ +import AssemblyAI +import Testing + +@testable import BlurtiOSCore + +@Suite("Words tab edits") +struct WordsEditTests { + @Test("adding key terms appends them, splitting on commas and dropping duplicates of any case") + func addsKeyTerms() { + #expect(WordsEdit.addingKeyTerms("Neil Bisht, blurt", to: ["Blurt"]) == ["Blurt", "Neil Bisht"]) + #expect(WordsEdit.addingKeyTerms(" ", to: ["Blurt"]) == ["Blurt"]) + } + + @Test("key terms stop at the request's term cap, so nothing is kept that couldn't be sent") + func keyTermsStopAtTheCap() { + let full = (0.. [String] { + guard let text else { return [] } + var seen = Set() + var result: [String] = [] + for piece in text.split(separator: ",") { + guard let term = String(piece).trimmedNonEmpty() else { continue } + guard seen.insert(term.lowercased()).inserted else { continue } + result.append(term) + } + return result + } + + // periphery:ignore - public for the iOS app (App/BlurtiOS), which the Mac scan doesn't index. + /// Terms as the comma-separated text `parse` reads back unchanged. + public static func join(_ terms: [String]) -> String { + terms.joined(separator: ", ") + } + /// Cap the API places on `config.keyterms_prompt`: 2048 characters summed /// across every term. Measured here in **UTF-8 bytes**, the conservative reading — the /// documented unit is "characters", which is unmeasured against the endpoint, - /// and bytes can only overestimate a multi-byte term's cost. Same conservative - /// choice, and the same reasoning, as `CleanupInstruction.characterCap`. + /// and bytes can only overestimate a multi-byte term's cost. /// /// Note this is the *boost* cap and a different number from the 4096 on - /// `config.stt_prompt` (`STTPrompt.characterCap`). Reusing + /// `config.stt_prompt`. Reusing /// one cap's figure for the other field is how a whole-request 400 shipped once /// before. - static let characterCap = 2048 + public static let characterCap = 2048 /// The *other* cap on the same field, and the one the byte budget above does /// not imply: at most **100 terms**, however short they are @@ -53,12 +63,12 @@ enum KeytermsBoost { /// 100 terms and 8000 characters in total"). Exceeding either is a 400 on the /// whole request, before the audio is read. /// - /// Reachable, which is why it is here: the terms are comma-split from one - /// free-text field, so 150 short ones cost ~1500 bytes and sail under + /// Reachable, which is why it is here: terms comma-split from one free-text + /// field (`parse`) are uncounted, so 150 short ones cost ~1500 bytes and sail under /// `characterCap` while being half again over this. That shape failed *every* /// dictation until the user shortened the list — the same outage a 3057-character /// cleanup instruction caused once, by the same mechanism. - static let termCap = 100 + public static let termCap = 100 /// The terms to send for `terms` — empty when there are none to send, which /// the encoders read as "omit the field", so the request asks for no boosting @@ -75,7 +85,7 @@ enum KeytermsBoost { /// nothing — and order is the user's, so the terms they typed first are the /// ones that survive a list too long to send. A term is never split and a /// blank one is never sent, so a stray entry can't cost the request. - static func fitted(_ terms: [String]) -> [String] { + public static func fitted(_ terms: [String]) -> [String] { var included: [String] = [] var remaining = characterCap for term in terms { diff --git a/Sources/BlurtEngine/Pipeline/TextShortcutExpander.swift b/Sources/AssemblyAI/Dictation/TextShortcuts.swift similarity index 75% rename from Sources/BlurtEngine/Pipeline/TextShortcutExpander.swift rename to Sources/AssemblyAI/Dictation/TextShortcuts.swift index 37ecf14f..4f2202fe 100644 --- a/Sources/BlurtEngine/Pipeline/TextShortcutExpander.swift +++ b/Sources/AssemblyAI/Dictation/TextShortcuts.swift @@ -1,9 +1,43 @@ import Foundation +/// One spoken phrase and the text it expands to — "personal email" → +/// `me@example.com`. Applied to the finished transcript on the device, after +/// the dictation response and before the text is inserted +/// (`TextShortcutExpander`); nothing about a shortcut goes on the wire. +/// +/// The `id` is minted once so a list and its editor can track a row across a +/// rename of its trigger. +public struct TextShortcut: Codable, Identifiable, Hashable, Sendable { + public let id: UUID + public var trigger: String + public var expansion: String + + public init(trigger: String, expansion: String) { + self.init(id: UUID(), trigger: trigger, expansion: expansion) + } + + public init(id: UUID, trigger: String, expansion: String) { + self.id = id + self.trigger = trigger + self.expansion = expansion + } + + /// The identity two triggers share when the expander can't tell them apart: + /// letters and digits only, lowercased, with the separators dropped — it + /// treats any separator run (or none) between words as equal, so "personal + /// email", "Personal-Email" and "personalemail" are one phrase. Empty for a + /// trigger with no letters or digits, which can never match anything. A list + /// should hold one shortcut per key: two expansions for one phrase could + /// only ever apply one of them. + public static func matchKey(for trigger: String) -> String { + TextShortcutExpander.words(in: trigger).joined().lowercased() + } +} + /// Replaces each spoken shortcut trigger in a finished transcript with its /// expansion — "my handle is personal GitHub" → "my handle is /// https://github.com/…". Pure and local: it runs on the text the dictation API -/// returned, between the response and the paste. +/// returned, between the response and the insertion. /// /// Matching is forgiving about what the transcription and the cleanup rewrite /// do to a phrase, and strict about where it starts and ends: @@ -14,7 +48,7 @@ import Foundation /// aren't letters or digits, or none at all — so "link tree" also matches /// "Linktree", "cal.com" matches "Cal com", and "mom's address" matches /// "Mom’s address". Only letters and digits in the trigger are significant, -/// which is exactly what `TextShortcutStore.matchKey` dedupes on. +/// which is exactly what `TextShortcut.matchKey` identifies a trigger by. /// - **Never across a sentence or clause break**: sentence punctuation /// (`.,!?;:`) followed by whitespace ends the run, so "work email" leaves "my /// work. Email me" alone while "cal.com" still matches. @@ -23,7 +57,7 @@ import Foundation /// /// One pass over the text, longest trigger first, so an expansion is never /// itself re-expanded and "personal email work" wins over "personal email". -enum TextShortcutExpander { +public enum TextShortcutExpander { /// What counts as part of a word — the regex spelling of /// `CharacterSet.alphanumerics` (letters, marks, digits), which `words(in:)` /// splits on. @@ -39,7 +73,7 @@ enum TextShortcutExpander { private static let separatorRun = "(?:[^\(wordClass)\(sentencePunctuation)]|[\(sentencePunctuation)](?!\\s))*" - static func expand(_ text: String, using shortcuts: [TextShortcut]) -> String { + public static func expand(_ text: String, using shortcuts: [TextShortcut]) -> String { // Longest first, so a trigger that extends another claims the text before // its prefix can. Group i+1 of `alternation` is `ordered[i]`. let ordered = shortcuts.compactMap { shortcut in pattern(for: shortcut.trigger).map { (shortcut, $0) } } @@ -50,7 +84,7 @@ enum TextShortcutExpander { // A trigger standing alone — surrounding whitespace and closing punctuation // aside — becomes exactly its expansion: a trailing period glued to an // address or URL is never what someone dictating a snippet on its own - // wanted. The whitespace is kept so the paste separator logic sees the same + // wanted. The whitespace is kept so an insertion's separator logic sees the same // shape. let source = text as NSString let whole = NSRange(location: 0, length: source.length) @@ -85,18 +119,18 @@ enum TextShortcutExpander { } /// `text` with every expansion in it put back to its trigger — the inverse of - /// `expand`, for the text before the caret that `STTPrompt` sends. After a - /// shortcut is pasted the field *holds* the saved replacement, and the next + /// `expand`, for the text before the caret sent as `stt_prompt`. After a + /// shortcut is inserted the field *holds* the saved replacement, and the next /// press reads it back; this keeps it local. /// - /// Literal and case-sensitive, since the field holds exactly what was pasted. + /// Literal and case-sensitive, since the field holds exactly what was inserted. /// Longest expansion first, so one that contains another is caught whole. The /// capture reads only the last few hundred characters, so a long expansion can /// arrive with its head cut off: a tail of one (at least /// `minimumClippedOverlap` long) at the very start of `text` counts too. /// Replaced with the trigger rather than nothing, so the prompt still reads as /// the continuous passage it is. - static func redactingExpansions(in text: String, using shortcuts: [TextShortcut]) -> String { + public static func redactingExpansions(in text: String, using shortcuts: [TextShortcut]) -> String { var result = text for shortcut in shortcuts.sorted(by: { $0.expansion.count > $1.expansion.count }) { let expansion = shortcut.expansion diff --git a/Sources/AssemblyAI/README.md b/Sources/AssemblyAI/README.md index 94d39105..881be173 100644 --- a/Sources/AssemblyAI/README.md +++ b/Sources/AssemblyAI/README.md @@ -10,9 +10,11 @@ a standalone Swift SDK. Foundation only; macOS 15 and iOS 18. | `ErrorResponse` | The dictation API's two documented error shapes. | | `DictationMultipart` | The upload's multipart framing: `config` first, then the PCM audio. | | `APIKeyValidator` | A cheap authenticated check that tells a rejected key from one it couldn't verify. | +| `KeyTerms` | Key terms for `keyterms_prompt`: parse a comma-separated list, fit it to the caps. | +| `TextShortcut`, `TextShortcutExpander` | Say a phrase, get saved text: expanded on the device, never sent. | What it deliberately doesn't hold: which prompt, cleanup instruction or key terms to send, where the -key is stored, or the `User-Agent`. Those are the app's choices and live in `BlurtEngine` -(`AssemblyAITranscriber`, `APIKeyValidator.blurt()`). +key, the key terms or the shortcuts are stored, or the `User-Agent`. Those are the app's choices and live in `BlurtEngine` +(`AssemblyAITranscriber`, `KeyTermsStore`, `TextShortcutStore`, `APIKeyValidator.blurt()`). Tests run with the engine's: `swift test` from the repo root. diff --git a/Sources/BlurtEngine/Audio/SoundPackCatalog.swift b/Sources/BlurtEngine/Audio/SoundPackCatalog.swift index 2112bd2c..ddeb589a 100644 --- a/Sources/BlurtEngine/Audio/SoundPackCatalog.swift +++ b/Sources/BlurtEngine/Audio/SoundPackCatalog.swift @@ -16,7 +16,7 @@ public struct SoundPackCatalog: Sendable { /// Distinct group names, in catalog order — the picker's sections. Deduped /// through a `Set` (`insert(_:).inserted` as the filter, matching - /// `KeyTermsStore.parse`) rather than an array `contains` scan per element, and + /// `KeyTerms.parse`) rather than an array `contains` scan per element, and /// resolved once at construction: the picker reads it, then `voices(in:)` per /// group, on every settings render. public let groups: [String] diff --git a/Sources/BlurtEngine/Config/APIKeySubmission.swift b/Sources/BlurtEngine/Config/APIKeySubmission.swift index c7dbb439..f7c12fac 100644 --- a/Sources/BlurtEngine/Config/APIKeySubmission.swift +++ b/Sources/BlurtEngine/Config/APIKeySubmission.swift @@ -1,4 +1,5 @@ import AssemblyAI + /// The validate-then-save flow behind the setup/settings screen's Save/Update /// button. Owned in the engine — rather than in the app coordinator — so its /// central invariant is unit-tested: **an unverified key never persists.** A key diff --git a/Sources/BlurtEngine/Config/KeyTermsStore.swift b/Sources/BlurtEngine/Config/KeyTermsStore.swift index 7c956f78..dc8ff22e 100644 --- a/Sources/BlurtEngine/Config/KeyTermsStore.swift +++ b/Sources/BlurtEngine/Config/KeyTermsStore.swift @@ -1,8 +1,9 @@ +import AssemblyAI import Foundation /// Storage for the user's dictation "key terms" — a comma-separated list of /// domain words (names, jargon, product names) sent as the dictation request's -/// keyterms list (`config.keyterms_prompt`, see `KeytermsBoost`), so the model +/// keyterms list (`config.keyterms_prompt`, see the SDK's `KeyTerms`), so the model /// favors those exact spellings. They used to ride the transcription `prompt` as /// a `Keywords: a, b, c.` clause instead; a flat list is the field the API /// provides for exactly this, so that is what they are now. @@ -21,7 +22,7 @@ import Foundation /// normalizing on write fought the text field, because the trimmed value was /// pushed back into the binding as an external change and a trailing space was /// deleted as the user typed it. Normalization lives on the read side instead -/// (`raw` trims, `parse` trims and dedupes), so a blank field still reads back +/// (`raw` trims, `KeyTerms.parse` trims and dedupes), so a blank field still reads back /// as "no terms". See the note on `KeyTermsStepView.text`. public struct KeyTermsStore { /// `UserDefaults` key for the raw, comma-separated string the user typed. @@ -42,27 +43,10 @@ public struct KeyTermsStore { defaults.string(forKey: Self.defaultsKey).trimmedNonEmpty() } - /// The stored terms parsed into a clean list: split on commas, trimmed, with - /// blanks and duplicates removed (case-insensitively, keeping first spelling). + /// The stored terms parsed into a clean list (`KeyTerms.parse`): split on + /// commas, trimmed, with blanks and duplicates removed (case-insensitively, + /// keeping first spelling). var terms: [String] { - Self.parse(raw) - } - - /// Pure parse of a comma-separated string into a clean term list. Static so - /// callers and tests can reuse the exact same rules without a `UserDefaults` - /// in hand. This is the whole normalization the request gets: `KeytermsBoost` - /// assumes terms arrive trimmed, blank-free and deduped, and only enforces the - /// field's length cap on top. - static func parse(_ text: String?) -> [String] { - guard let text else { return [] } - var seen = Set() - var result: [String] = [] - for piece in text.split(separator: ",") { - guard let term = String(piece).trimmedNonEmpty() else { continue } - let key = term.lowercased() - guard seen.insert(key).inserted else { continue } - result.append(term) - } - return result + KeyTerms.parse(raw) } } diff --git a/Sources/BlurtEngine/Config/TextShortcutStore.swift b/Sources/BlurtEngine/Config/TextShortcutStore.swift index e9f69933..29141389 100644 --- a/Sources/BlurtEngine/Config/TextShortcutStore.swift +++ b/Sources/BlurtEngine/Config/TextShortcutStore.swift @@ -1,29 +1,8 @@ +import AssemblyAI import Foundation -/// One spoken phrase and the text it expands to — "personal email" → -/// `me@example.com`. Applied to the finished transcript on the machine, after -/// the dictation response and before the paste (see `TextShortcutExpander`); -/// nothing about a shortcut goes on the wire. -/// -/// The `id` is minted once so the Settings list and its editor sheet can track -/// a row across a rename of its trigger. -public struct TextShortcut: Codable, Identifiable, Hashable, Sendable { - public let id: UUID - public var trigger: String - public var expansion: String - - public init(trigger: String, expansion: String) { - self.init(id: UUID(), trigger: trigger, expansion: expansion) - } - - init(id: UUID, trigger: String, expansion: String) { - self.id = id - self.trigger = trigger - self.expansion = expansion - } -} - -/// Storage for the user's text shortcuts, as one JSON list in `UserDefaults`. +/// Storage for the user's text shortcuts (the AssemblyAI SDK's `TextShortcut`), +/// as one JSON list in `UserDefaults` — Blurt's key and Blurt's limits. /// /// Has a setter for the same encoded-on-write reason as `StyleProfileStore`: /// the JSON shape is the store's business, so the Settings sheet writes through @@ -76,18 +55,9 @@ public struct TextShortcutStore { return Self.normalized(decoded) } - /// The identity two triggers share when the expander can't tell them apart: - /// letters and digits only, lowercased, with the separators dropped — it - /// treats any separator run (or none) between words as equal, so "personal - /// email", "Personal-Email" and "personalemail" are one phrase. Empty for a - /// trigger with no letters or digits, which can never match anything. - public static func matchKey(for trigger: String) -> String { - TextShortcutExpander.words(in: trigger).joined().lowercased() - } - /// The one funnel every list passes through, on read and on write: trimmed /// fields within their caps, entries with a blank or unmatchable trigger or a - /// blank expansion dropped, and triggers deduplicated by `matchKey` (first + /// blank expansion dropped, and triggers deduplicated by `TextShortcut.matchKey` (first /// wins — two expansions for one phrase could only ever apply one of them). /// Idempotent. static func normalized(_ shortcuts: [TextShortcut]) -> [TextShortcut] { @@ -99,7 +69,7 @@ public struct TextShortcutStore { let expansion = shortcut.expansion.trimmedNonEmpty().map({ String($0.prefix(expansionLimit)) }), - case let key = matchKey(for: trigger), !key.isEmpty, + case let key = TextShortcut.matchKey(for: trigger), !key.isEmpty, seen.insert(key).inserted else { continue } result.append(TextShortcut(id: shortcut.id, trigger: trigger, expansion: expansion)) diff --git a/Sources/BlurtEngine/Pipeline/DictationLog.swift b/Sources/BlurtEngine/Pipeline/DictationLog.swift index 4b4a51d9..0f66ad6d 100644 --- a/Sources/BlurtEngine/Pipeline/DictationLog.swift +++ b/Sources/BlurtEngine/Pipeline/DictationLog.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation import os @@ -51,7 +52,7 @@ public enum DictationLog { let sttPrompt: String /// The `config.keyterms_prompt` list sent for this utterance — /// the request's other steering field, so the log accounts for both. Built - /// through the same `KeytermsBoost.fitted` the request uses, so an + /// through the same `KeyTerms.fitted` the request uses, so an /// over-long list is recorded as the terms that actually went out. Empty /// when none were sent, and `encode(to:)` then omits the key, matching how /// every absent field above is left out rather than written as `null`. @@ -230,7 +231,7 @@ public enum DictationLog { app: context?.appName, window: context?.windowTitle, field: context?.fieldLabel, prior: context?.priorText, selected: context?.selectedText, sttPrompt: STTPrompt.text(context: context), - keyterms: KeytermsBoost.fitted(context?.keyTerms ?? []) + keyterms: KeyTerms.fitted(context?.keyTerms ?? []) ) } diff --git a/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift b/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift index 32bda486..4f14b5c9 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession+Pipeline.swift @@ -1,3 +1,4 @@ +import AssemblyAI import Foundation import os diff --git a/Sources/BlurtEngine/Pipeline/DictationSession.swift b/Sources/BlurtEngine/Pipeline/DictationSession.swift index a5361858..fe61d4b3 100644 --- a/Sources/BlurtEngine/Pipeline/DictationSession.swift +++ b/Sources/BlurtEngine/Pipeline/DictationSession.swift @@ -1,3 +1,4 @@ +import AssemblyAI // `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()` @@ -47,9 +48,8 @@ public actor DictationSession { let injector: InjectorProtocol /// Supplies the user's key terms (domain vocabulary) at press time, so each /// utterance's request boosts those spellings — as its own `keyterms_prompt` field - /// (`KeytermsBoost`), not as part of the conversation context. A closure, rather - /// than a stored list, so edits in Settings take effect on the next dictation - /// without rebuilding the session. Defaults to reading `KeyTermsStore`. + /// (`KeyTerms`), not as part of the conversation context. A closure, so edits in + /// Settings apply to the next dictation without rebuilding the session. Defaults to `KeyTermsStore`. let keyTermsProvider: @Sendable () -> [String] /// Names the style a completed dictation was made with, recorded onto its /// `RecentDictations.Entry` (the row's trailing style chip). `nil` means no diff --git a/Sources/BlurtEngine/README.md b/Sources/BlurtEngine/README.md index a0e6360f..18581bae 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:)`). 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 (`KeyTerms`, 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). @@ -215,8 +215,8 @@ The session calls `setTarget` at press time with the app that was frontmost when - **`TranscriptionContext`** carries the frontmost app name, window title, focused-field label, the text before the caret, the selected text (which a paste will replace), and the user's key terms — of which two are sent: `priorText` (as the prompt) and `keyTerms` (as the `keyterms_prompt` list). It's captured via Accessibility at press time (skipped in secure fields), off the hot path — and consumed when the upload opens, with a bounded wait (`DictationSession.contextWaitBudget`), so a hung read costs the request its priming rather than delaying the audio. That capture is macOS-only; a host on another platform passes a `DictationSession.HostFocusCapture` to the session's initializer — two closures supplying the frontmost app (or `nil`) and a `FocusedFieldContext` built from whatever its own text surface exposes, such as an iPhone keyboard's `documentContextBeforeInput` — and the session consumes it on the same schedule and with the same bounded wait. Of the focus signals only `priorText` is sent; the app name, window title, field label and selected text stay local — the injector reads `priorText`/`windowTitle` to decide the paste's leading separator and to recognize the captured window, and the developer-mode log records the rest. - **`STTPrompt.text(context:)`** renders the prior text into the dictation request's `config.stt_prompt` (transcription steering only — the cleanup rewrite is the separate `llm_instruction`): one newline-joined string, the user's recent dictations oldest-first followed by the text before the caret last, fitted to a **4096-scalar** budget by dropping the oldest entries first. That cap is the API's and it **rejects** rather than trims (`400 stt_prompt: String should have at most 4096 characters`), so `fitted` is what stands between a long history and every dictation failing; its unit was measured — 4096 `é` (8192 bytes) passes, 820 family emoji (4100 scalars, 820 graphemes) does not — which is why it counts `unicodeScalars` and not Swift's grapheme `count`. It reads no other field of the context. A context with neither history nor prior text yields `""`, which omits the field so the model works from the audio alone. History the prior chunk already ends with is dropped rather than sent twice — after a paste the field contains what was just dictated. **`stt_prompt` is `config.prompt` under its other name** (`provide only one of stt_prompt or prompt; they are the same field`), and it replaced `config.conversation_context`, the array-of-turns field that carried the same two signals; both still work, so re-adding the turn list would send the prior text twice rather than fail. The trade the swap accepted is that a custom prompt displaces the service's managed default; re-measured 2026-09-10, language detection survives it (a Spanish clip came back in Spanish with an English prompt, a Spanish one, and none at all). Two deliberate omissions survive from the older prompt, both regression-tested: no language steering (pinning to English hurt non-English speech) and no "remove filler words" clause (not in the model's trained instruction set — a no-op). Don't reintroduce either, don't put a `Previous transcript:` heading or any instruction in the string, and don't widen the context back out to the app/window/field hints the old prompt used to carry. - **`CleanupInstruction.text`** is the other half: the string sent as `config.llm_instruction`, which the service applies to the verbatim transcript with its own rewrite model. It is the winner of a GEPA run of `evals/dictation-prompt/`, compressed to fit the API's **2048-character** cap on that field — a different, smaller limit than the 4096 on `config.stt_prompt` — and counted in a different unit (UTF-8 bytes here, Unicode scalars there), so `CleanupInstruction.characterCap` is asserted in tests rather than borrowed. Treat it as a measured artifact: a new hypothesis belongs in that harness plus a fresh run, not a hand-edit. Two caveats ship with it — it has only ever been scored against other text candidates on a stand-in model, never against the service's own default instruction on its own rewrite model, and every corpus behind it is English. Dropping the field restores the service default in one line — which is _not_ the same as turning the rewrite off; nothing on the request does that, and the **enhanced transcripts** switch works on the response instead. -- **`KeytermsBoost.fitted(_:)`** turns those terms into the request's `config.keyterms_prompt` — keyterms prompting, the other steering field next to `stt_prompt`, and a flat array of strings rather than prose. It takes whole terms in the user's order while they fit the field's own **2048-character** cap (`KeytermsBoost.characterCap`, in UTF-8 bytes — a third limit, distinct from the 4096 scalars on `config.stt_prompt` and the 2048 bytes on `config.llm_instruction`) and while the list stays within **100 terms** (`KeytermsBoost.termCap`, the reference's `maxItems` on the same field — a count, not a size, and the one a byte budget doesn't imply: 150 short terms clear 2048 bytes and 400 on the count), and returns a plain `[String]`; an empty result omits the field on the wire rather than sending `[]`. `keyterms_prompt` is the canonical name — `keyterms` and `word_boost` are legacy aliases for the same feature, mutually exclusive with it, so send exactly one (this sent `word_boost` until 2026-09-10). Don't fold key terms back into the context as a `Keywords:` clause; that was the older, worse shape for the same intent. -- **`KeyTermsStore`** persists the user's domain vocabulary (names, jargon) in `UserDefaults`; `DictationSession` re-reads it at every press via its `keyTermsProvider` closure, so Settings edits apply to the next utterance without rebuilding the session. Pass your own provider to source terms from elsewhere. Its `parse` is the whole normalization (trim, drop blanks, dedupe case-insensitively) — `KeytermsBoost` assumes that and only enforces length. +- **`KeyTerms.fitted(_:)`** turns those terms into the request's `config.keyterms_prompt` — keyterms prompting, the other steering field next to `stt_prompt`, and a flat array of strings rather than prose. It takes whole terms in the user's order while they fit the field's own **2048-character** cap (`KeyTerms.characterCap`, in UTF-8 bytes — a third limit, distinct from the 4096 scalars on `config.stt_prompt` and the 2048 bytes on `config.llm_instruction`) and while the list stays within **100 terms** (`KeyTerms.termCap`, the reference's `maxItems` on the same field — a count, not a size, and the one a byte budget doesn't imply: 150 short terms clear 2048 bytes and 400 on the count), and returns a plain `[String]`; an empty result omits the field on the wire rather than sending `[]`. `keyterms_prompt` is the canonical name — `keyterms` and `word_boost` are legacy aliases for the same feature, mutually exclusive with it, so send exactly one (this sent `word_boost` until 2026-09-10). Don't fold key terms back into the context as a `Keywords:` clause; that was the older, worse shape for the same intent. +- **`KeyTermsStore`** persists the user's domain vocabulary (names, jargon) in `UserDefaults`; `DictationSession` re-reads it at every press via its `keyTermsProvider` closure, so Settings edits apply to the next utterance without rebuilding the session. Pass your own provider to source terms from elsewhere. Its `parse` is the whole normalization (trim, drop blanks, dedupe case-insensitively) — `KeyTerms` assumes that and only enforces length. For key storage, compose against **`APIKeyGateway`** — the injectable `current` / `save(_:)` / `hasKey` seam over the key store. `ProductionAPIKeyStore` forwards to the Keychain-backed `APIKeyStore`; `InMemoryAPIKeyStore` is a ready-made in-memory conformance for tests and harnesses (Blurt's XCUITest runs use it so the real Keychain item is never touched, and its `hasKey` backs the session's `readinessCheck`). For a settings UI, **`APIKeySubmission`** wraps the gateway with the validate-then-save flow (`submit(_:)` → valid / invalid / unreachable / saveFailed, via `APIKeyValidator`): it saves only a key AssemblyAI actively accepts, so an unverified key never persists. Two projections keep the surrounding UI out of your views: `Outcome.failureReport` classifies a failure as `.inline(message:)` (recoverable — show it beside the field) or `.alert(title:message:)` (a Keychain fault retyping can't fix), and **`APIKeyDisplay.resolve(key:)`** renders the stored key for an account row — masked tail, status and VoiceOver wording, and the connect-vs-rotate control titles. The mask reveals only the last `revealedTailLength` characters and, below `minimumLengthToMask`, none at all, so a short key can't be shown whole. diff --git a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift index ebddcf0a..873e9374 100644 --- a/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift +++ b/Sources/BlurtEngine/STT/AssemblyAITranscriber.swift @@ -115,7 +115,7 @@ public struct AssemblyAITranscriber: TranscriberProtocol { let config = try makeConfigData( sampleRate: sampleRate, sttPrompt: STTPrompt.text(context: context), - keytermsPrompt: KeytermsBoost.fitted(context?.keyTerms ?? [])) + keytermsPrompt: KeyTerms.fitted(context?.keyTerms ?? [])) var request = URLRequest(url: baseURL.appending(path: Self.transcribePath)) request.httpMethod = "POST" diff --git a/Sources/BlurtEngine/STT/STTPrompt.swift b/Sources/BlurtEngine/STT/STTPrompt.swift index 0b0ceb55..e5b3e346 100644 --- a/Sources/BlurtEngine/STT/STTPrompt.swift +++ b/Sources/BlurtEngine/STT/STTPrompt.swift @@ -1,3 +1,5 @@ +import AssemblyAI + /// Builds the dictation request's `config.stt_prompt` — the contextual prompt /// that primes the STT model with what came *before* this utterance. The model /// reads it as continuity: vocabulary, spelling, capitalization, and mid-sentence @@ -24,7 +26,7 @@ /// /// The key terms aren't here either, but for the opposite reason: they *are* /// sent, as their own request field. Keyterms prompting takes a flat list of -/// strings (`config.keyterms_prompt` — see `KeytermsBoost`), which is what the +/// strings (`config.keyterms_prompt` — see `KeyTerms`), which is what the /// Settings field collects. Don't fold them into this string — a /// `Keywords: a, b, c.` clause is the shape that field replaced. /// @@ -91,7 +93,7 @@ enum STTPrompt { /// and 820 family emoji — 820 grapheme clusters but 4100 scalars — is /// rejected with the message above. So Swift's `String.count`, which counts /// graphemes, would *under*-count and let a 400 through, and UTF-8 bytes (the - /// unit `KeytermsBoost` and `CleanupInstruction` use, where the server's own + /// unit `KeyTerms` and `CleanupInstruction` use, where the server's own /// unit is unmeasured) would needlessly halve the budget for any accented /// text. Scalars are exactly what the server counts. static let characterCap = 4096 @@ -117,7 +119,7 @@ enum STTPrompt { /// A plain `String`, not an optional one: "nothing to send" needs exactly one /// spelling, and the omit-vs-`""` distinction that matters to the API is /// stated once on the wire, in `DictationConfig.encode(to:)`. Same shape, and - /// the same reasoning, as `KeytermsBoost.fitted`. + /// the same reasoning, as `KeyTerms.fitted`. /// /// Every caller that reports or transmits the context goes through here — the /// transcriber and the developer-mode log both — so the log records exactly diff --git a/Sources/BlurtEngine/STT/TranscriptionContext.swift b/Sources/BlurtEngine/STT/TranscriptionContext.swift index f2fee713..d7e600b3 100644 --- a/Sources/BlurtEngine/STT/TranscriptionContext.swift +++ b/Sources/BlurtEngine/STT/TranscriptionContext.swift @@ -1,3 +1,5 @@ +import AssemblyAI + /// Everything captured at dictation start about where the user is typing: /// the frontmost app, the focused window and field, the text around the cursor, /// the user's recent dictations, and the user's key terms. @@ -66,7 +68,7 @@ public struct TranscriptionContext: Sendable, Equatable { /// from `KeyTermsStore`. Like `recentTranscripts` this isn't per-utterance /// focus state — it's the same list every time. **Sent**, but not as context: /// it goes as the request's own keyterms field, `keyterms_prompt` (see - /// `KeytermsBoost`). + /// `KeyTerms`). public let keyTerms: [String] /// The user's text shortcuts, from `TextShortcutStore`. **Not sent**: `STTPrompt` diff --git a/Tests/BlurtEngineTests/DictationLogTests.swift b/Tests/BlurtEngineTests/DictationLogTests.swift index bc2a769b..4b03d095 100644 --- a/Tests/BlurtEngineTests/DictationLogTests.swift +++ b/Tests/BlurtEngineTests/DictationLogTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine private struct DecodedEntry: Decodable { @@ -141,7 +142,7 @@ struct DictationLogEntryTests { @Test("records the key terms the request boosts, fitted the same way") func logsTheKeytermsTheRequestCarries() { - // Through `KeytermsBoost.fitted`, not the raw list: the log has to show the + // Through `KeyTerms.fitted`, not the raw list: the log has to show the // steering the API actually saw, so a blank entry is dropped here too. let withTerms = TranscriptionContext( appName: "Mail", priorText: "Hi Sam,", keyTerms: ["AssemblyAI", " ", "LeMUR"]) diff --git a/Tests/BlurtEngineTests/KeyTermsStoreTests.swift b/Tests/BlurtEngineTests/KeyTermsStoreTests.swift index 01456581..f8912120 100644 --- a/Tests/BlurtEngineTests/KeyTermsStoreTests.swift +++ b/Tests/BlurtEngineTests/KeyTermsStoreTests.swift @@ -3,36 +3,6 @@ import Testing @testable import BlurtEngine -@Suite("KeyTermsStore.parse") -struct KeyTermsStoreTests { - @Test("nil and blank input yield no terms") - func emptyInputs() { - #expect(KeyTermsStore.parse(nil).isEmpty) - #expect(KeyTermsStore.parse("").isEmpty) - #expect(KeyTermsStore.parse(" , , \n").isEmpty) - } - - @Test("comma-separated input splits and trims each term") - func splitsAndTrims() { - #expect(KeyTermsStore.parse("AssemblyAI, Kubernetes , Anthropic") == ["AssemblyAI", "Kubernetes", "Anthropic"]) - } - - @Test("blank entries between commas are dropped") - func dropsBlanks() { - #expect(KeyTermsStore.parse("foo,,bar, ,baz") == ["foo", "bar", "baz"]) - } - - @Test("duplicates are removed case-insensitively, keeping the first spelling") - func dedupesCaseInsensitively() { - #expect(KeyTermsStore.parse("Blurt, blurt, BLURT, Slack") == ["Blurt", "Slack"]) - } - - @Test("multi-word terms survive (only commas split)") - func multiWordTerms() { - #expect(KeyTermsStore.parse("San Francisco, New York") == ["San Francisco", "New York"]) - } -} - /// The read side, against an isolated defaults suite like every other store's /// suite (`freshDefaults()`). Each case writes the defaults slot directly, which /// is exactly how production writes it: the store has no setter, and the Settings diff --git a/Tests/BlurtEngineTests/KeytermsBoostTests.swift b/Tests/BlurtEngineTests/KeyTermsTests.swift similarity index 54% rename from Tests/BlurtEngineTests/KeytermsBoostTests.swift rename to Tests/BlurtEngineTests/KeyTermsTests.swift index c94d3225..96cd83bc 100644 --- a/Tests/BlurtEngineTests/KeytermsBoostTests.swift +++ b/Tests/BlurtEngineTests/KeyTermsTests.swift @@ -1,44 +1,45 @@ import Testing +@testable import AssemblyAI @testable import BlurtEngine -/// `KeytermsBoost.fitted` is the last thing between the user's Settings list and +/// `KeyTerms.fitted` is the last thing between the user's Settings list and /// `config.keyterms_prompt`. The list is the one unbounded input in the request, /// and over the field's cap the API rejects the whole request — so what these /// pin is that an over-long list degrades to fewer terms rather than to a failed /// dictation. -@Suite("KeytermsBoost") -struct KeytermsBoostTests { +@Suite("KeyTerms") +struct KeyTermsTests { @Test("no terms sends no field") func emptyListSendsNothing() { // Empty is the one "nothing to send" state, which the encoders turn into an // absent `keyterms_prompt` rather than a `[]` asking to boost nothing. - #expect(KeytermsBoost.fitted([]).isEmpty) + #expect(KeyTerms.fitted([]).isEmpty) } @Test("an all-blank list sends no field") func blankTermsSendNothing() { - #expect(KeytermsBoost.fitted(["", " ", "\n\t"]).isEmpty) + #expect(KeyTerms.fitted(["", " ", "\n\t"]).isEmpty) } @Test("terms pass through trimmed, in the user's order") func termsPassThroughInOrder() { // Order is the user's — it decides which terms survive a list too long to // send — and the API takes the strings as spellings, so they arrive trimmed. - #expect(KeytermsBoost.fitted(["AssemblyAI", " LeMUR ", "Blurt"]) == ["AssemblyAI", "LeMUR", "Blurt"]) + #expect(KeyTerms.fitted(["AssemblyAI", " LeMUR ", "Blurt"]) == ["AssemblyAI", "LeMUR", "Blurt"]) } @Test("blank entries are dropped from among real terms") func blanksDroppedAmongRealTerms() { // A stray comma in the Settings field must cost the request nothing. - #expect(KeytermsBoost.fitted(["AssemblyAI", " ", "LeMUR"]) == ["AssemblyAI", "LeMUR"]) + #expect(KeyTerms.fitted(["AssemblyAI", " ", "LeMUR"]) == ["AssemblyAI", "LeMUR"]) } @Test("an oversized list is fitted to the cap, keeping whole leading terms") func oversizedListIsFitted() { let terms = (0..<2000).map { "term\($0)" } - let fitted = KeytermsBoost.fitted(terms) - #expect(fitted.reduce(0) { $0 + $1.utf8.count } <= KeytermsBoost.characterCap) + let fitted = KeyTerms.fitted(terms) + #expect(fitted.reduce(0) { $0 + $1.utf8.count } <= KeyTerms.characterCap) #expect(fitted.first == "term0") #expect(fitted.count < terms.count) // Whole terms only — a truncated name boosts nothing, so every survivor is @@ -50,13 +51,13 @@ struct KeytermsBoostTests { func oneHugeTermSendsNothing() { // Not a clipped fragment: half a name is not the term the user asked to // boost, so the field is dropped whole. - #expect(KeytermsBoost.fitted([String(repeating: "k", count: KeytermsBoost.characterCap + 1)]).isEmpty) + #expect(KeyTerms.fitted([String(repeating: "k", count: KeyTerms.characterCap + 1)]).isEmpty) } @Test("a term that doesn't fit stops the list without dropping earlier ones") func overflowingTermStopsTheList() { - let huge = String(repeating: "k", count: KeytermsBoost.characterCap) - #expect(KeytermsBoost.fitted(["Blurt", huge, "LeMUR"]) == ["Blurt"]) + let huge = String(repeating: "k", count: KeyTerms.characterCap) + #expect(KeyTerms.fitted(["Blurt", huge, "LeMUR"]) == ["Blurt"]) } @Test("a list under the byte cap is still cut to the documented term cap") @@ -66,9 +67,9 @@ struct KeytermsBoostTests { // at most 100 items and 400s the whole request above that — so every // dictation would fail until the user shortened the field. let terms = (0..<150).map { "t\($0)" } - let fitted = KeytermsBoost.fitted(terms) - #expect(terms.reduce(0) { $0 + $1.utf8.count } < KeytermsBoost.characterCap) - #expect(fitted.count == KeytermsBoost.termCap) + let fitted = KeyTerms.fitted(terms) + #expect(terms.reduce(0) { $0 + $1.utf8.count } < KeyTerms.characterCap) + #expect(fitted.count == KeyTerms.termCap) // The user's order, first-typed wins — same rule as the byte cap. #expect(fitted.first == "t0") #expect(fitted.last == "t99") @@ -77,18 +78,55 @@ struct KeytermsBoostTests { @Test("a list at exactly the term cap is sent whole") func termCapBoundaryIsInclusive() { // Off-by-one guard on the boundary itself: 100 is allowed, 101 is not. - #expect(KeytermsBoost.fitted((0..<100).map { "t\($0)" }).count == 100) - #expect(KeytermsBoost.fitted((0..<101).map { "t\($0)" }).count == 100) + #expect(KeyTerms.fitted((0..<100).map { "t\($0)" }).count == 100) + #expect(KeyTerms.fitted((0..<101).map { "t\($0)" }).count == 100) } @Test("the boost cap is its own number, not the conversation context's") func capIsDistinctFromTheContextCap() { // Two caps on two fields of one request. Borrowing the other field's figure // is how an over-cap value shipped once before, so pin them apart. - #expect(KeytermsBoost.characterCap == 2048) - #expect(KeytermsBoost.characterCap != STTPrompt.characterCap) + #expect(KeyTerms.characterCap == 2048) + #expect(KeyTerms.characterCap != STTPrompt.characterCap) // And the term cap is a count, not a size — pinned so the two caps on this // one field can't be conflated either. - #expect(KeytermsBoost.termCap == 100) + #expect(KeyTerms.termCap == 100) + } +} + +@Suite("KeyTerms.parse") +struct KeyTermsParseTests { + @Test("nil and blank input yield no terms") + func emptyInputs() { + #expect(KeyTerms.parse(nil).isEmpty) + #expect(KeyTerms.parse("").isEmpty) + #expect(KeyTerms.parse(" , , \n").isEmpty) + } + + @Test("comma-separated input splits and trims each term") + func splitsAndTrims() { + #expect(KeyTerms.parse("AssemblyAI, Kubernetes , Anthropic") == ["AssemblyAI", "Kubernetes", "Anthropic"]) + } + + @Test("blank entries between commas are dropped") + func dropsBlanks() { + #expect(KeyTerms.parse("foo,,bar, ,baz") == ["foo", "bar", "baz"]) + } + + @Test("duplicates are removed case-insensitively, keeping the first spelling") + func dedupesCaseInsensitively() { + #expect(KeyTerms.parse("Blurt, blurt, BLURT, Slack") == ["Blurt", "Slack"]) + } + + @Test("multi-word terms survive (only commas split)") + func multiWordTerms() { + #expect(KeyTerms.parse("San Francisco, New York") == ["San Francisco", "New York"]) + } + + @Test("join is what parse reads back") + func joinRoundTrips() { + let terms = ["AssemblyAI", "San Francisco", "LeMUR"] + #expect(KeyTerms.join(terms) == "AssemblyAI, San Francisco, LeMUR") + #expect(KeyTerms.parse(KeyTerms.join(terms)) == terms) } } diff --git a/Tests/BlurtEngineTests/KeytermsWireTests.swift b/Tests/BlurtEngineTests/KeytermsWireTests.swift index e8672e41..a6206725 100644 --- a/Tests/BlurtEngineTests/KeytermsWireTests.swift +++ b/Tests/BlurtEngineTests/KeytermsWireTests.swift @@ -5,7 +5,7 @@ import Testing @testable import BlurtEngine /// The two steering fields of the dictation `config`, as they actually encode: -/// `KeytermsBoost` → `keyterms_prompt`, and `STTPrompt` → a single +/// `KeyTerms` → `keyterms_prompt`, and `STTPrompt` → a single /// `stt_prompt` string. An extension of the `HTTPClientTests` suite in its own /// file, exactly as the `APIKeyValidator` cases are — with its own private /// helper, since each of those files carries its own rather than sharing one diff --git a/Tests/BlurtEngineTests/PersistedSettingsTests.swift b/Tests/BlurtEngineTests/PersistedSettingsTests.swift index 0696bcb6..72618583 100644 --- a/Tests/BlurtEngineTests/PersistedSettingsTests.swift +++ b/Tests/BlurtEngineTests/PersistedSettingsTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// `PersistedSettings.allDefaultsKeys` exists so "add a store" and "add it to diff --git a/Tests/BlurtEngineTests/STTPromptTests.swift b/Tests/BlurtEngineTests/STTPromptTests.swift index 37dbbebe..afe50fd4 100644 --- a/Tests/BlurtEngineTests/STTPromptTests.swift +++ b/Tests/BlurtEngineTests/STTPromptTests.swift @@ -195,7 +195,7 @@ struct STTPromptTests { /// What the context is *not* allowed to carry. Every signal below is captured at /// press time and kept on the machine: the focus fields drive the paste path /// (the leading separator, the injector's window identity) and the developer-mode -/// log, and the key terms ride their own request field (`KeytermsBoost`) rather +/// log, and the key terms ride their own request field (`KeyTerms`) rather /// than this one. `text` is the only door to the wire, so this suite is what /// stands between a captured context and AssemblyAI's servers. @Suite("STTPrompt scope") diff --git a/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift b/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift index 71d4407e..760a2108 100644 --- a/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift +++ b/Tests/BlurtEngineTests/Stubs/DictationSessionTestSupport.swift @@ -1,6 +1,7 @@ import Foundation import Synchronization +@testable import AssemblyAI @testable import BlurtEngine /// A `DictationSession` plus the doubles a test configures or asserts against diff --git a/Tests/BlurtEngineTests/TextShortcutTests.swift b/Tests/BlurtEngineTests/TextShortcutTests.swift index 53787c53..35dce5f8 100644 --- a/Tests/BlurtEngineTests/TextShortcutTests.swift +++ b/Tests/BlurtEngineTests/TextShortcutTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine @Suite("TextShortcutExpander") @@ -147,7 +148,7 @@ struct TextShortcutStoreTests { TextShortcut(trigger: "...", expansion: "unmatchable"), ] #expect(store.shortcuts.map(\.expansion) == ["a@example.com"]) - #expect(TextShortcutStore.matchKey(for: "Cal.com") == TextShortcutStore.matchKey(for: "cal com")) + #expect(TextShortcut.matchKey(for: "Cal.com") == TextShortcut.matchKey(for: "cal com")) } @Test("an undecodable slot reads as no shortcuts") diff --git a/Tests/BlurtEngineTests/TranscriptionContextTests.swift b/Tests/BlurtEngineTests/TranscriptionContextTests.swift index b2337e41..969843ec 100644 --- a/Tests/BlurtEngineTests/TranscriptionContextTests.swift +++ b/Tests/BlurtEngineTests/TranscriptionContextTests.swift @@ -1,5 +1,6 @@ import Testing +@testable import AssemblyAI @testable import BlurtEngine /// `TranscriptionContext.isEmpty` is the gate `FocusCapture`/`DictationSession` @@ -47,7 +48,7 @@ struct TranscriptionContextTests { // Not in the context turns — they are sent as the request's word-boost list, // which is why a key-terms-only context is still worth carrying. #expect(STTPrompt.text(context: context).isEmpty) - #expect(KeytermsBoost.fitted(context.keyTerms) == ["Blurt"]) + #expect(KeyTerms.fitted(context.keyTerms) == ["Blurt"]) } @Test("recent dictations alone make it non-empty, and are what gets sent") diff --git a/scripts/mutate.sh b/scripts/mutate.sh index d0f02d8c..9425d073 100755 --- a/scripts/mutate.sh +++ b/scripts/mutate.sh @@ -47,6 +47,8 @@ cd "$REPO_ROOT" DEFAULT_TARGETS=( "Sources/BlurtEngine/Config/APIKeyDisplay.swift" "Sources/AssemblyAI/APIKeyValidator.swift" + "Sources/AssemblyAI/Dictation/KeyTerms.swift" + "Sources/AssemblyAI/Dictation/TextShortcuts.swift" "Sources/BlurtEngine/FocusCapture/FocusCapture+Pure.swift" "Sources/BlurtEngine/Hotkey/DictationKeyGate.swift" "Sources/BlurtEngine/Hotkey/DictationKeyRouter.swift" @@ -56,7 +58,6 @@ DEFAULT_TARGETS=( "Sources/BlurtEngine/Pipeline/OverlayPlacement.swift" "Sources/BlurtEngine/Pipeline/PipelinePhase.swift" "Sources/BlurtEngine/Pipeline/RecordingCueGate.swift" - "Sources/BlurtEngine/STT/KeytermsBoost.swift" "Sources/BlurtEngine/STT/STTPrompt.swift" "Sources/BlurtEngine/STT/SyncSTTLimits.swift" "Sources/BlurtEngine/StringNormalization.swift" From 6117e248fb3d298ac16ffdde12f822f8cdd53551 Mon Sep 17 00:00:00 2001 From: James He Date: Wed, 14 Oct 2026 19:20:32 -0500 Subject: [PATCH 8/9] feat(ios): show lifetime dictation stats (#233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(sdk): move key terms and text shortcuts into the AssemblyAI SDK KeyTerms (parse a comma-separated list, fit it to keyterms_prompt's caps; was KeytermsBoost plus KeyTermsStore.parse) and TextShortcut with its on-device TextShortcutExpander now live in Sources/AssemblyAI, so any app on the SDK gets them. BlurtEngine keeps Blurt's storage: KeyTermsStore and TextShortcutStore, their UserDefaults keys and limits. The iPhone's duplicate KeyTermList is replaced by KeyTerms. The Mac app only gains an import; its behaviour and stored data are unchanged. Co-Authored-By: Claude Opus 5.5 * feat(ios): add a Words tab for key terms and text shortcuts The app gets Dictate and Words tabs under a brand tab bar, as in the blurt-ios prototype. MainView owns what works on either tab: Settings from each tab's gear, the consent sheet and shared key-term lists. Words, on the AssemblyAI SDK's KeyTerms and TextShortcut: - Key terms as pills with an inline add field (commas add several, duplicates drop, capped at the request's 100), the contact-name count, and Share. They move here from Settings. - Text shortcuts with an inline "When I say / Type this" form; re-adding a phrase replaces it. Expanded on the phone, never sent. BlurtDesign gains BrandTabBar, FlowLayout, brandInput() and brandFootnote(), and brand buttons fade while disabled. Co-Authored-By: Claude Opus 5.5 * feat(ios): show lifetime dictation stats A stats card at the top of the Dictate tab, as in the blurt-ios prototype: dictations, words, and time saved against the 36 wpm phone typing average, as serif numbers with mono units. Time saved shows in full and moves to its own row past an hour; every value takes the largest of four sizes that fits. Every dictation counts, from the app or the keyboard. The coordinator times the recording phase and records on the main actor when the dictation's terminal phase renders, so the spoken time is the right one and a reset can't race a record. Totals stay on the phone (DictationStats, BlurtiOSCore); Settings › Data resets them. Co-Authored-By: Claude Opus 5.5 * style(sdk): keep KeyTerms.join's doc comment on its declaration Co-Authored-By: Claude Opus 5.5 * fix(ios): drop the Words tab pointer from Settings Co-Authored-By: Claude Opus 5.5 * feat(ios): add the Easter Egg (#234) * feat(ios): add the easter egg A quiet "How fast can you type?" link at the foot of Settings opens a 30-second sprint over common words, ported from the blurt-ios prototype: the clock starts on the first key, turns orange and thumps through the last five seconds, and the result shows WPM, a rank, accuracy, the best, and how talking at 150 wpm compares. The latest result becomes the typing speed time saved is measured against, in place of the 36 wpm phone average, with a "Reset to average" link beside it. Scoring and the speed live in BlurtiOSCore (TypingTest, TypingSpeed) and are tested. Co-Authored-By: Claude Opus 5.5 * fix(ios): present the Easter Egg once from Settings The cover and the reset confirmation were attached to their sections, and a section's modifiers apply to each of its rows, so the About section's five rows each tried to present the Easter Egg at once. Both now hang off the form. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 * feat(ios): add a Words tab for key terms and text shortcuts (#232) * feat(ios): add a Words tab for key terms and text shortcuts The app gets Dictate and Words tabs under a brand tab bar, as in the blurt-ios prototype. MainView owns what works on either tab: Settings from each tab's gear, the consent sheet and shared key-term lists. Words, on the AssemblyAI SDK's KeyTerms and TextShortcut: - Key terms as pills with an inline add field (commas add several, duplicates drop, capped at the request's 100), the contact-name count, and Share. They move here from Settings. - Text shortcuts with an inline "When I say / Type this" form; re-adding a phrase replaces it. Expanded on the phone, never sent. BlurtDesign gains BrandTabBar, FlowLayout, brandInput() and brandFootnote(), and brand buttons fade while disabled. Co-Authored-By: Claude Opus 5.5 * fix(ios): drop the Words tab pointer from Settings Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 * feat(ios): open on a setup screen, then keep setup in Settings (#236) * feat(ios): add the easter egg A quiet "How fast can you type?" link at the foot of Settings opens a 30-second sprint over common words, ported from the blurt-ios prototype: the clock starts on the first key, turns orange and thumps through the last five seconds, and the result shows WPM, a rank, accuracy, the best, and how talking at 150 wpm compares. The latest result becomes the typing speed time saved is measured against, in place of the 36 wpm phone average, with a "Reset to average" link beside it. Scoring and the speed live in BlurtiOSCore (TypingTest, TypingSpeed) and are tested. Co-Authored-By: Claude Opus 5.5 * fix(ios): present the Easter Egg once from Settings The cover and the reset confirmation were attached to their sections, and a section's modifiers apply to each of its rows, so the About section's five rows each tried to present the Easter Egg at once. Both now hang off the form. Co-Authored-By: Claude Opus 5.5 * feat(ios): open on a setup screen, then keep setup in Settings As in the blurt-ios prototype, a first-run screen replaces the tabs until Blurt is set up: the wordmark, a headline, and the numbered steps in a card, with the API key pasted inline (Continue, Get a free key, where the key and the audio go). Without a key it can't be left; with one, Start dictating goes to the tabs and the microphone and keyboard can wait. Whoever was already set up skips it. The setup card leaves the Dictate tab. Settings › Setup keeps the steps, hidden behind "Blurt is set up ✓ · Show steps" once everything is done and open while anything isn't. The rule is SetupProgress (BlurtiOSCore, tested); -BlurtNoKey shows a fresh install's screen in debug builds without touching the Keychain. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- App/BlurtiOS/BlurtDesign/DesignTokens.swift | 72 +++++ .../BlurtiOS/Sources/BlurtiOSApp.swift | 18 +- .../Sources/DictationCoordinator.swift | 36 ++- App/BlurtiOS/BlurtiOS/Sources/HomeView.swift | 15 +- App/BlurtiOS/BlurtiOS/Sources/MainView.swift | 23 ++ .../BlurtiOS/Sources/SettingsView.swift | 82 ++++++ .../{SetupCard.swift => SetupSteps.swift} | 40 ++- App/BlurtiOS/BlurtiOS/Sources/SetupView.swift | 121 ++++++++ App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift | 101 +++++++ .../BlurtiOS/Sources/TypingTestView.swift | 261 ++++++++++++++++++ .../BlurtiOSCore/DictationStats.swift | 105 +++++++ App/BlurtiOS/BlurtiOSCore/SetupProgress.swift | 14 + App/BlurtiOS/BlurtiOSCore/TypingTest.swift | 134 +++++++++ .../BlurtiOSTests/DictationStatsTests.swift | 65 +++++ .../BlurtiOSTests/SetupProgressTests.swift | 15 + .../BlurtiOSTests/TypingTestTests.swift | 54 ++++ App/BlurtiOS/DESIGN.md | 52 +++- App/BlurtiOS/Design/tokens.json | 96 +++++++ 18 files changed, 1264 insertions(+), 40 deletions(-) rename App/BlurtiOS/BlurtiOS/Sources/{SetupCard.swift => SetupSteps.swift} (68%) create mode 100644 App/BlurtiOS/BlurtiOS/Sources/SetupView.swift create mode 100644 App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift create mode 100644 App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/DictationStats.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/SetupProgress.swift create mode 100644 App/BlurtiOS/BlurtiOSCore/TypingTest.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/DictationStatsTests.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/SetupProgressTests.swift create mode 100644 App/BlurtiOS/BlurtiOSTests/TypingTestTests.swift diff --git a/App/BlurtiOS/BlurtDesign/DesignTokens.swift b/App/BlurtiOS/BlurtDesign/DesignTokens.swift index bae82a97..faebbb9a 100644 --- a/App/BlurtiOS/BlurtDesign/DesignTokens.swift +++ b/App/BlurtiOS/BlurtDesign/DesignTokens.swift @@ -251,6 +251,8 @@ package enum DesignTokens { package nonisolated static let appSetupNumberWidth: CGFloat = 24 /// Between a section's eyebrow and its content, and between cards. package nonisolated static let appStackGap: CGFloat = 12 + /// Between the stats in the card, and between its rows. + package nonisolated static let appStatGap: CGFloat = 16 /// Between a tab's icon and its label. package nonisolated static let appTabGap: CGFloat = 4 /// A tab in the brand tab bar: its icon over its mono label. @@ -381,6 +383,14 @@ package enum DesignTokens { package nonisolated static let ribsRestWidth: CGFloat = 40 /// Between rows, the iPhone's. package nonisolated static let rowGap: CGFloat = 11 + /// Between a stat's number-and-unit parts, as a fraction of its size. + package nonisolated static let statPartGapRatio: CGFloat = 0.38 + /// A stat number's tracking, as a fraction of its size. + package nonisolated static let statTrackingRatio: CGFloat = -0.03 + /// Between a number and its unit, as a fraction of its size. + package nonisolated static let statUnitGapRatio: CGFloat = 0.15 + /// A stat's mono unit (hr, min, sec) as a fraction of its number's size. + package nonisolated static let statUnitRatio: CGFloat = 0.46 /// The landing flash's vertical arm, as a fraction of the box's height. package nonisolated static let streakArm: CGFloat = 1.4 /// The light streak while recording. @@ -407,6 +417,18 @@ package enum DesignTokens { package nonisolated static let termPad: CGFloat = 14 /// The key-term field: the brand's input corners (radius/input). package nonisolated static let termRadius: CGFloat = 8 + /// The typing field's border, orange once a word goes wrong. + package nonisolated static let typingFieldBorder: CGFloat = 2 + /// Inside the typing field. + package nonisolated static let typingFieldPad: CGFloat = 12 + /// Between the lines of words to type. + package nonisolated static let typingLineSpacing: CGFloat = 8 + /// The countdown's thump in the last five seconds. + package nonisolated static let typingPulse: CGFloat = 1.06 + /// Between the result's blocks. + package nonisolated static let typingResultGap: CGFloat = 20 + /// The least room the words take, so the field below stays put. + package nonisolated static let typingWordsHeight: CGFloat = 180 package nonisolated static let voiceBarHeight: CGFloat = 32 /// The voice element's box in the voice bar — the most it takes (the ribs awake, the streak); the + sits beside what shows. package nonisolated static let voiceBarWidth: CGFloat = 160 @@ -459,6 +481,14 @@ package enum DesignTokens { package nonisolated static let sizePopup: CGFloat = 32 /// The × on a text-shortcut row. package nonisolated static let sizeRemoveGlyph: CGFloat = 12 + /// The next size down when the stats would not fit. + package nonisolated static let sizeStatL: CGFloat = 23 + /// And the next. + package nonisolated static let sizeStatM: CGFloat = 20 + /// The smallest a stat is drawn. + package nonisolated static let sizeStatS: CGFloat = 18 + /// A stat's serif number, at the largest size that fits. + package nonisolated static let sizeStatXl: CGFloat = 26 /// A tab's icon. package nonisolated static let sizeTabIcon: CGFloat = 18 /// A tab's mono label. @@ -467,6 +497,20 @@ package enum DesignTokens { package nonisolated static let sizeTerm: CGFloat = 17 /// The app's serif headline. package nonisolated static let sizeTitle: CGFloat = 34 + /// The typing test's countdown. + package nonisolated static let sizeTypingClock: CGFloat = 64 + /// What you've typed. + package nonisolated static let sizeTypingField: CGFloat = 22 + /// Its live words per minute. + package nonisolated static let sizeTypingLive: CGFloat = 28 + /// The result's rank, in the serif. + package nonisolated static let sizeTypingRank: CGFloat = 28 + /// The result's words per minute. + package nonisolated static let sizeTypingResult: CGFloat = 96 + /// The result's WPM. + package nonisolated static let sizeTypingUnit: CGFloat = 16 + /// The words to type. + package nonisolated static let sizeTypingWords: CGFloat = 24 /// Uppercase mono at 14: the brand's E2 tracking. package nonisolated static let trackingCta: CGFloat = 1.4 /// Uppercase mono at 12: the brand's E1 tracking. @@ -512,6 +556,10 @@ package enum DesignTokens { package nonisolated static let stateFade: Double = 0.5 /// The voice bar becoming the field. package nonisolated static let termSwap: Double = 0.4 + /// The typing field's border turning, and the countdown's thump. + package nonisolated static let typingFlash: Double = 0.15 + /// How often the typing test's clock and live speed redraw. + package nonisolated static let typingTick: Double = 0.1 /// The orb and the wave crossing, either way. package nonisolated static let waveFade: Double = 0.7 } @@ -638,6 +686,7 @@ package enum DesignTokens { "metrics.app/section-gap": "24", "metrics.app/setup-number-width": "24", "metrics.app/stack-gap": "12", + "metrics.app/stat-gap": "16", "metrics.app/tab-gap": "4", "metrics.app/tab-height": "52", "metrics.card/border": "1", @@ -709,6 +758,10 @@ package enum DesignTokens { "metrics.ribs/rest-height": "16", "metrics.ribs/rest-width": "40", "metrics.row/gap": "11", + "metrics.stat/part-gap-ratio": "0.38", + "metrics.stat/tracking-ratio": "-0.03", + "metrics.stat/unit-gap-ratio": "0.15", + "metrics.stat/unit-ratio": "0.46", "metrics.streak/arm": "1.4", "metrics.streak/height": "2", "metrics.streak/line": "1", @@ -722,6 +775,12 @@ package enum DesignTokens { "metrics.term/inset": "2", "metrics.term/pad": "14", "metrics.term/radius": "8", + "metrics.typing/field-border": "2", + "metrics.typing/field-pad": "12", + "metrics.typing/line-spacing": "8", + "metrics.typing/pulse": "1.06", + "metrics.typing/result-gap": "20", + "metrics.typing/words-height": "180", "metrics.voice/bar-height": "32", "metrics.voice/bar-width": "160", "metrics.voice/home-height": "112", @@ -750,10 +809,21 @@ package enum DesignTokens { "type.size/pill-glyph": "10", "type.size/popup": "32", "type.size/remove-glyph": "12", + "type.size/stat-l": "23", + "type.size/stat-m": "20", + "type.size/stat-s": "18", + "type.size/stat-xl": "26", "type.size/tab-icon": "18", "type.size/tab-label": "11", "type.size/term": "17", "type.size/title": "34", + "type.size/typing-clock": "64", + "type.size/typing-field": "22", + "type.size/typing-live": "28", + "type.size/typing-rank": "28", + "type.size/typing-result": "96", + "type.size/typing-unit": "16", + "type.size/typing-words": "24", "type.tracking/cta": "1.4", "type.tracking/eyebrow": "1.2", "type.weight/glyph": "medium", @@ -778,6 +848,8 @@ package enum DesignTokens { "motion.sheen-working": "1.6", "motion.state-fade": "0.5", "motion.term-swap": "0.4", + "motion.typing-flash": "0.15", + "motion.typing-tick": "0.1", "motion.wave-fade": "0.7", ] } diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index 9eb84874..3d65085b 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -26,7 +26,7 @@ struct BlurtiOSApp: App { // touches neither the Keychain nor the App Group (the Mac's UITestSupport // does the same for its harness), and `start()` below never runs. let coordinator = - Self.isTestHost + Self.isTestHost || Self.isKeyless ? DictationCoordinator(apiKey: APIKeyModel(keyStore: InMemoryAPIKeyStore())) : DictationCoordinator() _coordinator = State(initialValue: coordinator) } @@ -35,6 +35,17 @@ struct BlurtiOSApp: App { ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil } + /// `-BlurtNoKey`: a fresh install's view of the key, in memory, so the + /// first-run screen can be screenshotted without touching the Keychain's; + /// debug builds only. + private static var isKeyless: Bool { + #if DEBUG + CommandLine.arguments.contains("-BlurtNoKey") + #else + false + #endif + } + var body: some Scene { WindowGroup { #if DEBUG @@ -45,6 +56,9 @@ struct BlurtiOSApp: App { probe.task { await start() } } else if let voice = KeyboardGalleryView.VoiceStillView.parse(CommandLine.arguments) { voice + } else if CommandLine.arguments.contains("-BlurtTypingTest") { + // The easter egg as the root, for a screenshot without a tap. + TypingTestView() } else if CommandLine.arguments.contains("-BlurtSettings") { // The settings sheet as the root, for a screenshot without a tap. SettingsView(coordinator: coordinator) @@ -60,7 +74,7 @@ struct BlurtiOSApp: App { } private var home: some View { - MainView(coordinator: coordinator) + RootView(coordinator: coordinator) .task { await start() } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index bf0598d8..fd24ecda 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift @@ -52,6 +52,14 @@ final class DictationCoordinator { nonisolated private static let pressKeyboard = Mutex(nil) /// Contact names, parsed once per lexicon refresh rather than on the press. nonisolated private static let lexiconNames = Mutex<[String]>([]) + /// The words the session just delivered, waiting to be counted. The stats are + /// recorded on the main actor when the dictation's terminal phase renders — + /// after its recording has been timed, and in the same place a reset runs — + /// rather than on the session's actor, where neither ordering holds. + nonisolated private static let deliveredText = Mutex(nil) + /// When the recording in progress started, and how long the last one ran. + @ObservationIgnored private var recordingSince: ContinuousClock.Instant? + @ObservationIgnored private var lastSpokenSeconds: Double = 0 init(apiKey: APIKeyModel = APIKeyModel()) { let window = ListeningWindow() @@ -66,7 +74,10 @@ final class DictationCoordinator { injector: KeyboardRelayInjector(presser: { Self.pressKeyboard.withLock { $0 } }), keyTermsProvider: { Self.keyTerms() }, readinessCheck: apiKey.readinessCheck(), - onTranscriptDelivered: { _, ring in continuation.yield(ring) }, + onTranscriptDelivered: { text, ring in + Self.deliveredText.withLock { $0 = text } + continuation.yield(ring) + }, // The keyboard is the only thing that can see the field: the press // command carries the text before the cursor, and that is what primes // the request — the same signal the Mac reads through Accessibility. @@ -243,6 +254,17 @@ final class DictationCoordinator { private func render(_ phase: PipelinePhase) { self.phase = phase if phase == .recording { window.extend() } + timeRecording(phase) + // Delivery always comes before the paste and its terminal phase, and the + // phases render in order, so the dictation's words are waiting by now. + if phase.isTerminal, + let text = Self.deliveredText.withLock({ text in + defer { text = nil } + return text + }) + { + DictationStats.record(text, spokenFor: lastSpokenSeconds) + } // The Mac shows a setup blocker as calm idle beside a settings button; // the keyboard has no such button, so it hears an error and the orb says so. if phase.setupBlocker != nil { @@ -252,6 +274,18 @@ final class DictationCoordinator { publish(phase.overlayState) } + /// Starts the clock when a recording starts and, when it stops, keeps how + /// long it ran for the stats. + private func timeRecording(_ phase: PipelinePhase) { + if phase == .recording { + if recordingSince == nil { recordingSince = .now } + } else if let since = recordingSince { + let elapsed = since.duration(to: .now) + lastSpokenSeconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18 + recordingSince = nil + } + } + private func observeLevels() -> Task { let levels = window.source.levels return Task { [weak self] in diff --git a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift index d6abf538..55cb1ccf 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -5,10 +5,11 @@ import BlurtiOSCore import SwiftUI /// The Dictate tab: the Blurt landing page on a phone. The wordmark and -/// the gear across the top; the status as an eyebrow, a serif headline and a +/// the gear across the top; the lifetime stats; the status as an eyebrow, a serif headline and a /// line of body text over the voice element; the one green button; then the -/// styles and the recent dictations under their eyebrows. Setup sits on top -/// while anything is missing; everything adjustable lives behind the gear. +/// styles and the recent dictations under their eyebrows. Setup comes before +/// the tabs (`SetupView`) and lives in Settings after; everything adjustable +/// is behind the gear. struct HomeView: View { var coordinator: DictationCoordinator /// Opens Settings, which `MainView` presents over either tab. @@ -16,7 +17,6 @@ struct HomeView: View { @Environment(\.scenePhase) private var scenePhase @State private var microphoneGranted = AVAudioApplication.shared.recordPermission == .granted @State private var keyboardSeen = SharedStore.keyboardEverSeen - @State private var showsKeyEntry = false /// When the last dictation's words landed — the hero's glint. @State private var landedAt: Date? /// TEMPORARY: the mic concept from Settings, so the hero shows the one @@ -35,11 +35,7 @@ struct HomeView: View { ScrollView { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { header - if !status.isSetUp { - SetupCard( - hasKey: coordinator.apiKey.hasAPIKey, microphoneGranted: microphoneGranted, keyboardSeen: keyboardSeen, - enterKey: { showsKeyEntry = true }, refresh: refreshStatus) - } + StatsCard() HomeHero(coordinator: coordinator, status: status, landedAt: landedAt) StyleChips() RecentSection(coordinator: coordinator) @@ -52,7 +48,6 @@ struct HomeView: View { .page() .environment(\.voiceElementKind, VoiceElementKind(rawValue: voiceRaw) ?? .shipped) .toolbar(.hidden, for: .navigationBar) - .sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) } .onChange(of: scenePhase) { _, phase in if phase == .active { refreshStatus() } } diff --git a/App/BlurtiOS/BlurtiOS/Sources/MainView.swift b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift index ac615bf4..cdb4c0c3 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/MainView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift @@ -1,7 +1,30 @@ +import AVFoundation import BlurtDesign import BlurtiOSCore import SwiftUI +/// The first-run setup screen until Blurt is set up, then the tabs +/// (`SetupProgress`). Whoever was already set up when this arrived goes +/// straight to the tabs: the check is taken once, at launch, so finishing the +/// last step on the setup screen doesn't yank it away mid-read. +struct RootView: View { + var coordinator: DictationCoordinator + @AppStorage(SetupProgress.finishedKey) private var finished = false + @State private var wasSetUpAtLaunch = + AVAudioApplication.shared.recordPermission == .granted && SharedStore.keyboardEverSeen + + var body: some View { + if SetupProgress.needsSetup( + hasKey: coordinator.apiKey.hasAPIKey, isSetUp: wasSetUpAtLaunch, finished: finished) + { + SetupView(coordinator: coordinator) + .onOpenURL { coordinator.handle($0) } + } else { + MainView(coordinator: coordinator) + } + } +} + /// The app: the Dictate and Words tabs under the brand tab bar, plus what has /// to work whichever tab is showing — Settings from either tab's gear, the /// consent prompt the keyboard's `blurt://start` can raise, and a shared diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 3e19377e..a4b89913 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -1,3 +1,4 @@ +import AVFoundation import BlurtDesign import BlurtEngine import BlurtiOSCore @@ -19,16 +20,25 @@ struct SettingsView: View { @AppStorage(BlurtShared.Key.voiceElement, store: SharedStore.defaults) private var voiceRaw = VoiceElementKind.shipped.rawValue @State private var showsKeyEntry = false + @State private var confirmsStatsReset = false + @State private var showsTypingTest = false + @State private var showsSetupSteps = false + @State private var microphoneGranted = AVAudioApplication.shared.recordPermission == .granted + @State private var keyboardSeen = SharedStore.keyboardEverSeen + @Environment(\.scenePhase) private var scenePhase + @AppStorage(TypingSpeed.defaultsKey) private var typingSpeed: Double = 0 @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue var body: some View { NavigationStack { Form { + setupSection keyboardSection listeningSection transcriptionSection accountSection + dataSection aboutSection } .brandForm() @@ -38,10 +48,58 @@ struct SettingsView: View { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } .sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) } + // On the form, not on a section: a section's modifiers apply to each of + // its rows, so a cover there was presented once per row at the same time. + .fullScreenCover(isPresented: $showsTypingTest) { TypingTestView() } + .onChange(of: scenePhase) { _, phase in + if phase == .active { refreshSetup() } + } + .confirmationDialog("Reset statistics?", isPresented: $confirmsStatsReset, titleVisibility: .visible) { + Button("Reset", role: .destructive) { DictationStats.reset() } + } message: { + Text("Your dictation, word and time-saved totals go back to zero.") + } } .tint(BlurtBrand.accent) } + /// The first-run steps, kept here once the app is set up, as in the + /// blurt-ios prototype: folded to a line while everything is done, open while + /// something (the microphone, the keyboard) is still missing. + private var setupSection: some View { + let isSetUp = coordinator.apiKey.hasAPIKey && microphoneGranted && keyboardSeen + return Section { + if isSetUp { + HStack { + Text("Blurt is set up") + Image(systemName: "checkmark").foregroundStyle(BlurtBrand.accent) + Spacer() + Button(showsSetupSteps ? "Hide steps" : "Show steps") { showsSetupSteps.toggle() } + .font(BlurtType.body(DesignTokens.Typography.sizeCaption, bold: true)) + .foregroundStyle(BlurtBrand.accent) + .buttonStyle(.plain) + } + } + if !isSetUp || showsSetupSteps { + SetupSteps( + hasKey: coordinator.apiKey.hasAPIKey, microphoneGranted: microphoneGranted, keyboardSeen: keyboardSeen, + refresh: refreshSetup + ) { + Button("Add an API key") { showsKeyEntry = true }.buttonStyle(BrandButtonStyle()) + } + .padding(.vertical, DesignTokens.Metrics.appRowPadY) + } + } header: { + Eyebrow("Setup") + } + } + + private func refreshSetup() { + microphoneGranted = AVAudioApplication.shared.recordPermission == .granted + keyboardSeen = SharedStore.keyboardEverSeen + coordinator.apiKey.refreshStatus() + } + private var keyboardSection: some View { Section { Picker("Layout", selection: $layout) { @@ -119,12 +177,36 @@ struct SettingsView: View { } } + /// The stats card's totals, which live only on this phone. + private var dataSection: some View { + Section { + Button("Reset statistics", role: .destructive) { confirmsStatsReset = true } + } header: { + Eyebrow("Data") + } footer: { + Text("Dictations, words and time saved are counted on this phone and never sent.") + } + } + private var aboutSection: some View { Section { LabeledContent("Version", value: Self.version) Link("Blurt on GitHub", destination: URL(string: "https://github.com/AssemblyAI/blurt") ?? URL(filePath: "/")) Link("AssemblyAI's privacy policy", destination: AIConsent.privacyPolicyURL) Text("Powered by AssemblyAI").foregroundStyle(BlurtBrand.muted) + // The easter egg: a link, not a button, so it stays a find rather than + // another call to action. Its result becomes time saved's typing speed. + Button { + showsTypingTest = true + } label: { + Label("How fast can you type?", systemImage: "keyboard") + } + .foregroundStyle(BlurtBrand.accent) + if typingSpeed > 0 { + Button("Using your \(Int(typingSpeed)) wpm · Reset to average") { TypingSpeed.resetToAverage() } + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.muted) + } } header: { Eyebrow("About") } diff --git a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift b/App/BlurtiOS/BlurtiOS/Sources/SetupSteps.swift similarity index 68% rename from App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift rename to App/BlurtiOS/BlurtiOS/Sources/SetupSteps.swift index f64ed81c..d53bd720 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SetupSteps.swift @@ -1,38 +1,29 @@ import AVFoundation import BlurtDesign -import BlurtEngine import SwiftUI import UIKit -/// The setup checklist, on top of the home screen while anything is missing: -/// three numbered steps — an AssemblyAI API key, the microphone, the keyboard — -/// each with its control until it is done. -struct SetupCard: View { +/// The setup steps, numbered: an AssemblyAI API key, the microphone, the +/// keyboard, each with its control until it is done and a check after. The +/// first-run screen shows them in a card; Settings keeps them once the app is +/// set up, folded away until asked for. +struct SetupSteps: View { let hasKey: Bool let microphoneGranted: Bool let keyboardSeen: Bool - /// Every build takes an API key, as the Mac app does, until Sign in with - /// AssemblyAI exists. - let enterKey: () -> Void /// Re-read the permissions after one was asked for. let refresh: () -> Void + /// What the key step offers while there's no key: the inline field on the + /// first-run screen. + @ViewBuilder let keyStep: () -> KeyStep var body: some View { VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { - Eyebrow("Set up Blurt") - SetupRow(number: "01", done: hasKey, title: "Add your AssemblyAI API key") { - VStack(alignment: .leading, spacing: DesignTokens.Metrics.appLineGap) { - Text("Blurt uses your own AssemblyAI account. New accounts come with free credits.") - .font(BlurtType.body(DesignTokens.Typography.sizeCaption)).foregroundStyle(BlurtBrand.muted) - Button("Paste your key") { enterKey() }.buttonStyle(BrandButtonStyle(role: .primary)) - Link("Get a free key", destination: APIKeyStore.dashboardURL) - .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) - } - } + SetupRow(number: "01", done: hasKey, title: "Add your AssemblyAI API key", action: keyStep) SetupRow(number: "02", done: microphoneGranted, title: "Allow the microphone") { if AVAudioApplication.shared.recordPermission == .denied { // iOS asks once; after a refusal only Settings can change it. - Button("Allow in Settings") { Self.openAppSettings() }.buttonStyle(BrandButtonStyle(role: .primary)) + Button("Allow in Settings") { SystemSettings.open() }.buttonStyle(BrandButtonStyle(role: .primary)) } else { Button("Allow") { Task { @@ -56,16 +47,17 @@ struct SetupCard: View { + "Full Access is what lets the keyboard send your words to Blurt." ) .font(BlurtType.body(DesignTokens.Typography.sizeCaption)).foregroundStyle(BlurtBrand.muted) - Button("Open Settings") { Self.openAppSettings() }.buttonStyle(BrandButtonStyle(role: .secondary)) + Button("Open Settings") { SystemSettings.open() }.buttonStyle(BrandButtonStyle(role: .secondary)) } } } - .padding(DesignTokens.Metrics.appCardPad) - .frame(maxWidth: .infinity, alignment: .leading) - .card() } - static func openAppSettings() { +} + +/// Blurt's page in the Settings app. +enum SystemSettings { + static func open() { guard let url = URL(string: UIApplication.openSettingsURLString) else { return } UIApplication.shared.open(url) } diff --git a/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift b/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift new file mode 100644 index 00000000..541a2db7 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/SetupView.swift @@ -0,0 +1,121 @@ +import AVFoundation +import BlurtDesign +import BlurtEngine +import BlurtiOSCore +import SwiftUI + +/// The first-run screen, in place of the tabs until Blurt is set up, as in the +/// blurt-ios prototype: the wordmark, a headline, and the three steps in a +/// card. The key comes first and can't be skipped, since nothing works without +/// it; once it is in, the user can start right away and finish the microphone +/// and the keyboard later from Settings, where the steps move. +struct SetupView: View { + var coordinator: DictationCoordinator + @Environment(\.scenePhase) private var scenePhase + @AppStorage(SetupProgress.finishedKey) private var finished = false + @State private var microphoneGranted = AVAudioApplication.shared.recordPermission == .granted + @State private var keyboardSeen = SharedStore.keyboardEverSeen + + private var hasKey: Bool { coordinator.apiKey.hasAPIKey } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { + Wordmark() + .frame(height: DesignTokens.Metrics.appHeaderHeight) + Text( + hasKey ? "Two more steps and you can dictate anywhere." : "Paste an AssemblyAI API key to start dictating." + ) + .font(BlurtType.heading(DesignTokens.Typography.sizeTitle)) + .foregroundStyle(BlurtBrand.text) + .fixedSize(horizontal: false, vertical: true) + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { + Eyebrow("Set up Blurt") + SetupSteps( + hasKey: hasKey, microphoneGranted: microphoneGranted, keyboardSeen: keyboardSeen, refresh: refresh + ) { + KeyStep(apiKey: coordinator.apiKey) + } + } + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + if hasKey { + Button("Start dictating") { finished = true } + .buttonStyle(BrandButtonStyle()) + Text("You can finish these later in Settings.") + .brandFootnote() + } + } + .padding(.horizontal, DesignTokens.Metrics.appPagePad) + .padding(.bottom, DesignTokens.Metrics.appSectionGap) + } + .scrollDismissesKeyboard(.interactively) + .page() + .tint(BlurtBrand.accent) + .onChange(of: scenePhase) { _, phase in + if phase == .active { refresh() } + } + } + + private func refresh() { + microphoneGranted = AVAudioApplication.shared.recordPermission == .granted + keyboardSeen = SharedStore.keyboardEverSeen + coordinator.apiKey.refreshStatus() + } +} + +/// Paste a key, checked with AssemblyAI before it is saved (`APIKeySubmission` +/// owns that rule), as on the prototype's first screen: the field, Continue, +/// Get a free key, and what happens to the key and the audio. +private struct KeyStep: View { + var apiKey: APIKeyModel + @Environment(\.openURL) private var openURL + @State private var draft = "" + @State private var isChecking = false + @State private var errorMessage: String? + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStackGap) { + SecureField("Paste your API key", text: $draft) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.go) + .onSubmit { Task { await submit() } } + .brandInput() + if let errorMessage { + Text(errorMessage) + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.errorOrange) + .fixedSize(horizontal: false, vertical: true) + } + // Stacked, not side by side: at half the card's width the mono labels + // wrap ("GET A / FREE KEY"). + VStack(spacing: DesignTokens.Metrics.appChipGap) { + Button(isChecking ? "Checking…" : "Continue") { Task { await submit() } } + .buttonStyle(BrandButtonStyle()) + .disabled(isChecking || draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Button("Get a free key") { openURL(APIKeyStore.dashboardURL) } + .buttonStyle(BrandButtonStyle(role: .secondary)) + } + Text( + "Blurt sends what you dictate to AssemblyAI to transcribe it, using this key. " + + "New accounts come with free credits. The key is stored in this iPhone's Keychain." + ) + .brandFootnote() + } + } + + private func submit() async { + guard !isChecking else { return } + isChecking = true + defer { isChecking = false } + switch await apiKey.submit(draft).failureReport { + case .none: + errorMessage = nil + draft = "" + case .some(.inline(let message)): errorMessage = message + case .some(.alert(let title, let message)): errorMessage = "\(title) \(message)" + } + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift b/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift new file mode 100644 index 00000000..4c4bdd86 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift @@ -0,0 +1,101 @@ +import BlurtDesign +import BlurtiOSCore +import SwiftUI + +/// Lifetime dictations, words, and time saved over typing them, at the top of +/// the Dictate tab. Every dictation counts, from the app or the keyboard; the +/// totals stay on the phone (`DictationStats`). +struct StatsCard: View { + @AppStorage(DictationStats.defaultsKey) private var raw = Data() + /// The speed time saved is measured against: the phone average, or the + /// easter egg's latest result (`TypingSpeed`). + @AppStorage(TypingSpeed.defaultsKey) private var typingSpeed: Double = 0 + + var body: some View { + let stats = DictationStats.decode(raw) + let saved = stats.timeSaved(typingWordsPerMinute: TypingSpeed.resolved(typingSpeed)) + let dictations = [(StatFormat.count(stats.dictations), String?.none)] + let words = [(StatFormat.count(stats.words), String?.none)] + let timeSaved = StatFormat.durationParts(saved).map { ($0.value, Optional($0.unit)) } + let sizes = DesignTokens.Typography.self + // Under an hour, all three share one row. Once time saved reaches hours + // ("1 hr 19 min 50 sec") it moves to a full-width second row. Either way + // every value is the same size: the largest that fits. + // + // The candidates are listed out, not generated with ForEach: ViewThatFits + // traps on generated children whose identities repeat across branches. + ViewThatFits(in: .horizontal) { + if saved < .seconds(3_600) { + oneRow(dictations, words, timeSaved, size: sizes.sizeStatXl) + oneRow(dictations, words, timeSaved, size: sizes.sizeStatL) + oneRow(dictations, words, timeSaved, size: sizes.sizeStatM) + oneRow(dictations, words, timeSaved, size: sizes.sizeStatS) + } + twoRows(dictations, words, timeSaved, size: sizes.sizeStatXl) + twoRows(dictations, words, timeSaved, size: sizes.sizeStatL) + twoRows(dictations, words, timeSaved, size: sizes.sizeStatM) + twoRows(dictations, words, timeSaved, size: sizes.sizeStatS) + } + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + } + + private typealias Parts = [(String, String?)] + + private func oneRow(_ dictations: Parts, _ words: Parts, _ timeSaved: Parts, size: CGFloat) -> some View { + HStack(alignment: .top, spacing: 0) { + Stat(label: "Dictations", parts: dictations, size: size) + Spacer(minLength: DesignTokens.Metrics.appStatGap) + Stat(label: "Words", parts: words, size: size) + Spacer(minLength: DesignTokens.Metrics.appStatGap) + Stat(label: "Time saved", parts: timeSaved, size: size) + } + } + + private func twoRows(_ dictations: Parts, _ words: Parts, _ timeSaved: Parts, size: CGFloat) -> some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appStatGap) { + HStack(alignment: .top, spacing: 0) { + Stat(label: "Dictations", parts: dictations, size: size) + .frame(maxWidth: .infinity, alignment: .leading) + Stat(label: "Words", parts: words, size: size) + .frame(maxWidth: .infinity, alignment: .leading) + } + Stat(label: "Time saved", parts: timeSaved, size: size) + } + } +} + +/// One stat: a mono eyebrow over its value, drawn as number-and-unit pairs +/// (serif numbers, mono units) so "1 hr 25 min" reads as a measurement. Units +/// scale with the number, so every size keeps the same proportions. +private struct Stat: View { + let label: String + let parts: [(value: String, unit: String?)] + let size: CGFloat + + var body: some View { + let metrics = DesignTokens.Metrics.self + VStack(alignment: .leading, spacing: metrics.appLineGap) { + Eyebrow(label) + HStack(alignment: .firstTextBaseline, spacing: size * metrics.statPartGapRatio) { + ForEach(Array(parts.enumerated()), id: \.offset) { _, part in + HStack(alignment: .firstTextBaseline, spacing: size * metrics.statUnitGapRatio) { + Text(part.value) + .font(BlurtType.heading(size)) + .tracking(size * metrics.statTrackingRatio) + .foregroundStyle(BlurtBrand.text) + if let unit = part.unit { + Text(unit.uppercased()) + .font(BlurtType.mono((size * metrics.statUnitRatio).rounded(), weight: .medium)) + .foregroundStyle(BlurtBrand.muted) + } + } + } + } + } + .lineLimit(1) + .fixedSize() + .accessibilityElement(children: .combine) + } +} diff --git a/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift b/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift new file mode 100644 index 00000000..b9a98e29 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift @@ -0,0 +1,261 @@ +import BlurtDesign +import BlurtiOSCore +import SwiftUI +import UIKit + +/// The easter egg: a 30-second typing sprint, full screen, from Settings. The +/// clock starts on the first keystroke, the last five seconds turn orange and +/// thump, and the result becomes the typing speed time saved is measured +/// against, in place of the 36 wpm phone average. +struct TypingTestView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private enum Phase: Equatable { + case ready + case running(since: Date) + case finished(wordsPerMinute: Double, accuracy: Double, result: TypingSpeed.Result) + + var startDate: Date? { + if case .running(let since) = self { return since } + return nil + } + } + + @State private var test = TypingTest() + @State private var phase = Phase.ready + @State private var input = "" + @State private var best = TypingSpeed.best() + @FocusState private var isTyping: Bool + + private static let seconds = Double(TypingTest.duration.components.seconds) + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: DesignTokens.Metrics.appIcon, weight: DesignTokens.Typography.weightGlyph)) + .foregroundStyle(BlurtBrand.muted) + .frame(width: DesignTokens.Metrics.appHeaderHeight, height: DesignTokens.Metrics.appHeaderHeight) + } + .accessibilityLabel("Close") + Spacer() + Eyebrow("How fast can you type?") + Spacer() + Color.clear.frame(width: DesignTokens.Metrics.appHeaderHeight, height: DesignTokens.Metrics.appHeaderHeight) + } + + switch phase { + case .ready, .running: + sprint + case .finished(let wordsPerMinute, let accuracy, let result): + results(wordsPerMinute: wordsPerMinute, accuracy: accuracy, result: result) + } + } + .padding(.horizontal, DesignTokens.Metrics.appPagePad) + .page() + .onAppear { isTyping = true } + .task(id: phase.startDate) { await runClock() } + } + + // MARK: - The sprint + + private var sprint: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { + TimelineView(.periodic(from: .now, by: DesignTokens.Motion.typingTick)) { context in + let remaining = remainingSeconds(at: context.date) + let isFinalStretch = + phase.startDate != nil && remaining <= Double(TypingTest.finalStretch.components.seconds) + HStack(alignment: .firstTextBaseline) { + Text(String(format: "%.1f", remaining)) + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingClock, weight: .bold)) + .monospacedDigit() + .foregroundStyle(isFinalStretch ? BlurtBrand.errorOrange : BlurtBrand.text) + .scaleEffect( + isFinalStretch && !reduceMotion && pulse(at: context.date) ? DesignTokens.Metrics.typingPulse : 1, + anchor: .leading + ) + .animation(.easeOut(duration: DesignTokens.Motion.typingFlash), value: Int(remaining)) + Spacer() + VStack(alignment: .trailing, spacing: DesignTokens.Metrics.appLineGap) { + Eyebrow("WPM") + Text(liveWordsPerMinute(at: context.date)) + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingLive, weight: .bold)) + .monospacedDigit() + .foregroundStyle(BlurtBrand.accent) + } + } + .accessibilityElement(children: .combine) + } + + wordsText + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingWords, weight: .medium)) + .lineSpacing(DesignTokens.Metrics.typingLineSpacing) + .frame(maxWidth: .infinity, minHeight: DesignTokens.Metrics.typingWordsHeight, alignment: .topLeading) + .accessibilityLabel("Next word: \(test.currentWord)") + + TextField("Start typing to begin", text: $input) + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingField)) + .foregroundStyle(BlurtBrand.text) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + .focused($isTyping) + .padding(DesignTokens.Metrics.typingFieldPad) + .background(BlurtBrand.cardFill, in: RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput)) + .overlay( + RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput) + .strokeBorder( + input.isEmpty || test.isOnTrack(input) ? BlurtBrand.cardBorder : BlurtBrand.errorOrange, + lineWidth: DesignTokens.Metrics.typingFieldBorder) + ) + .animation(.easeInOut(duration: DesignTokens.Motion.typingFlash), value: test.isOnTrack(input)) + .onChange(of: input) { _, typed in handle(typed) } + + Text(phase == .ready ? "30 seconds. The clock starts on your first key." : "Space after each word.") + .brandFootnote() + Spacer(minLength: 0) + } + } + + /// A stable block of words: it moves on in chunks of eight, so the words + /// under your eyes don't jump while you type. + private var wordsText: Text { + let start = (test.index / 8) * 8 + var text = Text("") + for position in start..<(start + 20) { + var styled = Text(test.words[position % test.words.count]) + if position < test.index { + styled = + test.outcomes[position] + ? styled.foregroundStyle(BlurtBrand.muted) + : styled.foregroundStyle(BlurtBrand.errorOrange).strikethrough() + } else if position == test.index { + styled = styled.foregroundStyle(BlurtBrand.accent).bold().underline() + } else { + styled = styled.foregroundStyle(BlurtBrand.text) + } + text = text + styled + Text(" ") + } + return text + } + + private func handle(_ typed: String) { + if phase == .ready, !typed.isEmpty { phase = .running(since: .now) } + guard case .running = phase, let last = typed.last, last.isWhitespace else { return } + let word = typed.trimmingCharacters(in: .whitespaces) + input = "" + guard !word.isEmpty else { return } + if test.submit(word) { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } else { + UINotificationFeedbackGenerator().notificationOccurred(.error) + } + } + + /// Runs once per sprint: waits out the clock, thumps through the final + /// stretch, then scores. + private func runClock() async { + guard let start = phase.startDate else { return } + let stretch = Int(TypingTest.finalStretch.components.seconds) + let untilStretch = start.addingTimeInterval(Self.seconds - Double(stretch)).timeIntervalSinceNow + try? await Task.sleep(for: .seconds(max(0, untilStretch))) + for _ in 0.. Double { + guard let start = phase.startDate else { return Self.seconds } + return max(0, Self.seconds - date.timeIntervalSince(start)) + } + + private func liveWordsPerMinute(at date: Date) -> String { + guard let start = phase.startDate else { return "--" } + let elapsed = date.timeIntervalSince(start) + // The first couple of seconds swing wildly, so hold off. + guard elapsed >= 2 else { return "--" } + return "\(Int(test.wordsPerMinute(after: .seconds(min(elapsed, Self.seconds))).rounded()))" + } + + private func pulse(at date: Date) -> Bool { + date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1) < DesignTokens.Motion.typingFlash + } + + // MARK: - The result + + private func results(wordsPerMinute: Double, accuracy: Double, result: TypingSpeed.Result) -> some View { + let rank = TypingTest.rank(for: wordsPerMinute) + let rounded = Int(wordsPerMinute.rounded()) + let speedup = TypingTest.speakingWordsPerMinute / max(wordsPerMinute, 1) + return VStack(alignment: .leading, spacing: DesignTokens.Metrics.typingResultGap) { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appChipGap) { + Eyebrow( + result.isBest ? "New personal best" : "Best \(Int(best.rounded())) wpm", + color: result.isBest ? BlurtBrand.accent : BlurtBrand.muted) + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Metrics.appChipGap) { + Text("\(rounded)") + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingResult, weight: .bold)) + .foregroundStyle(BlurtBrand.text) + Text("WPM") + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingUnit, weight: .medium)) + .foregroundStyle(BlurtBrand.muted) + } + Text(rank.title) + .font(BlurtType.heading(DesignTokens.Typography.sizeTypingRank)) + .foregroundStyle(BlurtBrand.text) + Text(rank.line) + .font(BlurtType.body(DesignTokens.Typography.sizeBody)) + .foregroundStyle(BlurtBrand.text) + } + .accessibilityElement(children: .combine) + + Eyebrow("\(Int((accuracy * 100).rounded()))% accuracy") + + Text( + speedup >= 1.5 + ? "Talking runs about 150 words a minute. That's \(String(format: "%.1f", speedup))× you." + : "Talking runs about 150 words a minute. Honestly, you might not need us." + ) + .font(BlurtType.body(DesignTokens.Typography.sizeBody)) + .foregroundStyle(BlurtBrand.text) + .fixedSize(horizontal: false, vertical: true) + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + + Eyebrow( + result.setsSpeed + ? "Time saved now uses your \(rounded) wpm" : "Too few words to measure. Time saved is unchanged", + color: result.setsSpeed ? BlurtBrand.accent : BlurtBrand.muted) + + Button("Try again", action: restart) + .buttonStyle(BrandButtonStyle()) + Spacer(minLength: 0) + } + } +} diff --git a/App/BlurtiOS/BlurtiOSCore/DictationStats.swift b/App/BlurtiOS/BlurtiOSCore/DictationStats.swift new file mode 100644 index 00000000..cb5a12b5 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/DictationStats.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Lifetime totals for the Dictate tab's stats card: dictations, words, and +/// time spent talking. On the phone only, in the app's own defaults — from +/// the app and the keyboard alike, since every dictation runs in the app. +/// +/// Time saved is derived from the totals rather than stored, so it follows +/// whichever typing speed it's measured against. +package nonisolated struct DictationStats: Codable, Equatable, Sendable { + /// Average phone typing speed, the baseline for time saved: 36 words per + /// minute (Palin et al., "How do people type on mobile devices?", MobileHCI + /// 2019). + package static let averageTypingWordsPerMinute: Double = 36 + + package private(set) var dictations = 0 + package private(set) var words = 0 + package private(set) var secondsSpoken: Double = 0 + + package init() {} + + /// How long typing every dictated word would have taken at `wordsPerMinute`, + /// minus the time actually spent talking. Never negative. + package func timeSaved(typingWordsPerMinute wordsPerMinute: Double = averageTypingWordsPerMinute) -> Duration { + guard wordsPerMinute > 0 else { return .zero } + let typingSeconds = Double(words) / wordsPerMinute * 60 + return .seconds(max(0, typingSeconds - secondsSpoken)) + } + + /// One delivered dictation: the words as inserted (text shortcuts expanded, + /// since that's what wasn't typed) and how long the user spoke. Empty text + /// isn't a dictation. + package mutating func record(_ text: String, spokenFor seconds: Double) { + let count = Self.wordCount(of: text) + guard count > 0 else { return } + dictations += 1 + words += count + secondsSpoken += max(0, seconds) + } + + /// Linguistic words, not whitespace runs, so punctuation and languages + /// written without spaces count sensibly. + package static func wordCount(of text: String) -> Int { + var count = 0 + text.enumerateSubstrings(in: text.startIndex..., options: [.byWords, .substringNotRequired]) { _, _, _, _ in + count += 1 + } + return count + } + + // MARK: - Persistence + + /// The JSON-encoded totals, a `Data` slot so a view can observe it with + /// `@AppStorage` and redraw as dictations land. + package static let defaultsKey = "DictationStats" + + package static func load(from defaults: UserDefaults = .standard) -> DictationStats { + decode(defaults.data(forKey: defaultsKey)) + } + + /// The totals behind a slot value a view already observed; blank or + /// unreadable is no dictations yet. + package static func decode(_ data: Data?) -> DictationStats { + data.flatMap { try? JSONDecoder().decode(DictationStats.self, from: $0) } ?? DictationStats() + } + + package func save(to defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(self) { defaults.set(data, forKey: Self.defaultsKey) } + } + + /// Records one dictation straight into `defaults`. The app calls this and + /// `reset` from the main actor only, so a reset can't land between a + /// record's read and its write. + package static func record(_ text: String, spokenFor seconds: Double, in defaults: UserDefaults = .standard) { + var stats = load(from: defaults) + stats.record(text, spokenFor: seconds) + stats.save(to: defaults) + } + + package static func reset(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: defaultsKey) + } +} + +/// The stats card's renderings. Counts stay short; time saved is shown in full +/// ("1 hr 25 min 45 sec"), because watching every second add up is the point. +package nonisolated enum StatFormat { + /// A count, compact past four digits: `9,999`, then `12K`. + package static func count(_ value: Int) -> String { + value < 10_000 ? value.formatted() : value.formatted(.number.notation(.compactName)) + } + + /// A duration as number-and-unit parts, largest first, skipping zero parts: + /// `[("1", "hr"), ("25", "min"), ("45", "sec")]`. Under a second is `0 sec`. + package static func durationParts(_ duration: Duration) -> [(value: String, unit: String)] { + var remaining = Int(duration.components.seconds) + guard remaining > 0 else { return [("0", "sec")] } + var parts: [(String, String)] = [] + for (unit, size) in [("day", 86_400), ("hr", 3_600), ("min", 60), ("sec", 1)] { + let amount = remaining / size + remaining %= size + if amount > 0 { parts.append((amount.formatted(), unit)) } + } + return parts + } +} diff --git a/App/BlurtiOS/BlurtiOSCore/SetupProgress.swift b/App/BlurtiOS/BlurtiOSCore/SetupProgress.swift new file mode 100644 index 00000000..6ccc8be6 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/SetupProgress.swift @@ -0,0 +1,14 @@ +/// Whether the app opens on its first-run setup screen or on its tabs, as in +/// the blurt-ios prototype: setup first, then the app, with the steps moving +/// into Settings once they are done. +package nonisolated enum SetupProgress { + /// Set when the user leaves the setup screen with a key in place, so the + /// steps they skipped (the microphone, the keyboard) don't hold the app back. + package static let finishedKey = "SetupFinished" + + /// The setup screen shows without a key, which nothing works without, and + /// until the user has either finished every step or chosen to start anyway. + package static func needsSetup(hasKey: Bool, isSetUp: Bool, finished: Bool) -> Bool { + !hasKey || (!finished && !isSetUp) + } +} diff --git a/App/BlurtiOS/BlurtiOSCore/TypingTest.swift b/App/BlurtiOS/BlurtiOSCore/TypingTest.swift new file mode 100644 index 00000000..5bd9e3fc --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/TypingTest.swift @@ -0,0 +1,134 @@ +import Foundation + +/// The easter egg behind Settings: a 30-second sprint over random common words +/// that measures how fast the user types. Pure state, so the scoring is +/// tested; `TypingTestView` owns the clock and the theatrics. +/// +/// Scored the standard way: a "word" is five characters, and only correctly +/// typed words count, each with its trailing space. The word being typed when +/// time runs out doesn't count. +package nonisolated struct TypingTest: Sendable { + package static let duration: Duration = .seconds(30) + /// Seconds left when the clock turns and starts to thump. + package static let finalStretch: Duration = .seconds(5) + /// Conversational speech runs about 150 words a minute, the comparison the + /// results end on. + package static let speakingWordsPerMinute: Double = 150 + + package let words: [String] + package private(set) var index = 0 + package private(set) var outcomes: [Bool] = [] + private var correctCharacters = 0 + + package init(words: [String] = TypingTest.randomWords()) { + self.words = words.isEmpty ? ["blurt"] : words + } + + package var currentWord: String { words[index % words.count] } + + /// Scores `typed` against the current word and moves on. Returns whether it + /// was right. + @discardableResult + package mutating func submit(_ typed: String) -> Bool { + let isCorrect = typed == currentWord + outcomes.append(isCorrect) + if isCorrect { correctCharacters += typed.count + 1 } + index += 1 + return isCorrect + } + + /// Whether what's typed so far could still become the current word: the + /// field turns orange mid-word when it can't. + package func isOnTrack(_ partial: String) -> Bool { currentWord.hasPrefix(partial) } + + package func wordsPerMinute(after elapsed: Duration) -> Double { + let minutes = Double(elapsed.components.seconds) / 60 + Double(elapsed.components.attoseconds) / 6e19 + guard minutes > 0 else { return 0 } + return Double(correctCharacters) / 5 / minutes + } + + package var accuracy: Double { + guard !outcomes.isEmpty else { return 0 } + return Double(outcomes.filter { $0 }.count) / Double(outcomes.count) + } + + package struct Rank: Equatable, Sendable { + package let title: String + package let line: String + } + + package static func rank(for wordsPerMinute: Double) -> Rank { + switch wordsPerMinute { + case ..<25: Rank(title: "Thumb wrestler", line: "Your thumbs are doing their best.") + case ..<40: Rank(title: "Steady thumbs", line: "Right around the phone average.") + case ..<55: Rank(title: "Speed thumbs", line: "Faster than most people on a phone.") + case ..<75: Rank(title: "Thumb demon", line: "Seriously quick.") + default: Rank(title: "Are you even human?", line: "We'd like to study your thumbs.") + } + } + + /// Plenty for a 30-second sprint even at 200 wpm. + package static func randomWords(count: Int = 120) -> [String] { + (0.. Double { + stored > 0 ? stored : DictationStats.averageTypingWordsPerMinute + } + + package static func current(in defaults: UserDefaults = .standard) -> Double { + resolved(defaults.double(forKey: defaultsKey)) + } + + package static func best(in defaults: UserDefaults = .standard) -> Double { + defaults.double(forKey: bestKey) + } + + /// Records a finished sprint: a new best is kept, and a meaningful result + /// becomes the time-saved speed, rounded as it's shown. + package static func record(_ wordsPerMinute: Double, in defaults: UserDefaults = .standard) -> Result { + let isBest = wordsPerMinute > best(in: defaults) + if isBest { defaults.set(wordsPerMinute, forKey: bestKey) } + let setsSpeed = wordsPerMinute >= minimumMeaningful + if setsSpeed { defaults.set(wordsPerMinute.rounded(), forKey: defaultsKey) } + return Result(isBest: isBest, setsSpeed: setsSpeed) + } + + package static func resetToAverage(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: defaultsKey) + } + + package struct Result: Equatable, Sendable { + package let isBest: Bool + package let setsSpeed: Bool + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/DictationStatsTests.swift b/App/BlurtiOS/BlurtiOSTests/DictationStatsTests.swift new file mode 100644 index 00000000..6c591a50 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/DictationStatsTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing + +@testable import BlurtiOSCore + +@Suite("Dictation stats") +struct DictationStatsTests { + private func scratchDefaults() throws -> UserDefaults { + let name = "DictationStatsTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test("a dictation adds one, its words, and the time spoken; empty text adds nothing") + func records() { + var stats = DictationStats() + stats.record("Hello there, world.", spokenFor: 2) + stats.record(" ", spokenFor: 5) + #expect(stats.dictations == 1) + #expect(stats.words == 3) + #expect(stats.secondsSpoken == 2) + } + + @Test("words are linguistic words, not whitespace runs") + func wordCount() { + #expect(DictationStats.wordCount(of: "Well — don't stop, okay?") == 4) + #expect(DictationStats.wordCount(of: "") == 0) + } + + @Test("time saved is the typing time at the speed, less the time spent talking, never negative") + func timeSaved() { + var stats = DictationStats() + stats.record(Array(repeating: "word", count: 72).joined(separator: " "), spokenFor: 30) + // 72 words at 36 wpm is two minutes of typing; half a minute was spent talking. + #expect(stats.timeSaved() == .seconds(90)) + #expect(stats.timeSaved(typingWordsPerMinute: 1_000) == .zero) + #expect(stats.timeSaved(typingWordsPerMinute: 0) == .zero) + } + + @Test("totals persist, accumulate across dictations, and reset to nothing") + func persists() throws { + let defaults = try scratchDefaults() + #expect(DictationStats.load(from: defaults) == DictationStats()) + DictationStats.record("one two", spokenFor: 1, in: defaults) + DictationStats.record("three", spokenFor: 1, in: defaults) + let loaded = DictationStats.load(from: defaults) + #expect(loaded.dictations == 2) + #expect(loaded.words == 3) + DictationStats.reset(in: defaults) + #expect(DictationStats.load(from: defaults) == DictationStats()) + #expect(DictationStats.decode(Data("not json".utf8)) == DictationStats()) + } + + @Test("counts compact past four digits; durations read largest unit first, skipping zeros") + func formats() { + #expect(StatFormat.count(9_999) == 9_999.formatted()) + #expect(StatFormat.count(12_000) == 12_000.formatted(.number.notation(.compactName))) + #expect(StatFormat.durationParts(.zero).map(\.unit) == ["sec"]) + let parts = StatFormat.durationParts(.seconds(3_600 + 25 * 60 + 45)) + #expect(parts.map(\.value) == ["1", "25", "45"]) + #expect(parts.map(\.unit) == ["hr", "min", "sec"]) + #expect(StatFormat.durationParts(.seconds(86_400 + 5)).map(\.unit) == ["day", "sec"]) + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/SetupProgressTests.swift b/App/BlurtiOS/BlurtiOSTests/SetupProgressTests.swift new file mode 100644 index 00000000..110ee641 --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/SetupProgressTests.swift @@ -0,0 +1,15 @@ +import Testing + +@testable import BlurtiOSCore + +@Suite("First-run setup") +struct SetupProgressTests { + @Test("no key always means the setup screen; a key plus finishing or every step done means the tabs") + func routing() { + #expect(SetupProgress.needsSetup(hasKey: false, isSetUp: false, finished: true)) + #expect(SetupProgress.needsSetup(hasKey: false, isSetUp: true, finished: true)) + #expect(SetupProgress.needsSetup(hasKey: true, isSetUp: false, finished: false)) + #expect(!SetupProgress.needsSetup(hasKey: true, isSetUp: false, finished: true)) + #expect(!SetupProgress.needsSetup(hasKey: true, isSetUp: true, finished: false)) + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/TypingTestTests.swift b/App/BlurtiOS/BlurtiOSTests/TypingTestTests.swift new file mode 100644 index 00000000..65a0e1ba --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/TypingTestTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing + +@testable import BlurtiOSCore + +@Suite("Easter egg typing sprint") +struct TypingTestTests { + private func scratchDefaults() throws -> UserDefaults { + let name = "TypingTestTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test("only right words score, five characters to a word with its space; accuracy counts every word") + func scoring() { + var test = TypingTest(words: ["hello", "there", "world"]) + #expect(test.isOnTrack("hel")) + #expect(!test.isOnTrack("hex")) + let first = test.submit("hello") + let second = test.submit("thare") + #expect(first) + #expect(!second) + #expect(test.currentWord == "world") + // "hello" plus its space is six characters: 1.2 words in half a minute. + #expect(test.wordsPerMinute(after: .seconds(30)) == 2.4) + #expect(test.accuracy == 0.5) + #expect(test.wordsPerMinute(after: .zero) == 0) + #expect(TypingTest(words: []).currentWord == "blurt") + } + + @Test("the ranks climb with speed") + func ranks() { + #expect(TypingTest.rank(for: 10).title == "Thumb wrestler") + #expect(TypingTest.rank(for: 36).title == "Steady thumbs") + #expect(TypingTest.rank(for: 200).title == "Are you even human?") + #expect(TypingTest.randomWords(count: 7).count == 7) + } + + @Test("a result sets time saved's speed and the best; too few words changes nothing; reset goes back to average") + func speed() throws { + let defaults = try scratchDefaults() + #expect(TypingSpeed.current(in: defaults) == DictationStats.averageTypingWordsPerMinute) + #expect(TypingSpeed.record(52.4, in: defaults) == .init(isBest: true, setsSpeed: true)) + #expect(TypingSpeed.current(in: defaults) == 52) + #expect(TypingSpeed.record(40, in: defaults) == .init(isBest: false, setsSpeed: true)) + #expect(TypingSpeed.current(in: defaults) == 40) + #expect(TypingSpeed.best(in: defaults) == 52.4) + #expect(TypingSpeed.record(2, in: defaults) == .init(isBest: false, setsSpeed: false)) + #expect(TypingSpeed.current(in: defaults) == 40) + TypingSpeed.resetToAverage(in: defaults) + #expect(TypingSpeed.current(in: defaults) == DictationStats.averageTypingWordsPerMinute) + } +} diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index 65ad9778..7da9f358 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -182,10 +182,21 @@ point sizes throughout, as the system keyboard's; no Dynamic Type. | `size/pill-glyph` | `10` | `Typography.sizePillGlyph` | the × on a key-term pill | | `size/popup` | `32` | `Typography.sizePopup` | the letter pop-up | | `size/remove-glyph` | `12` | `Typography.sizeRemoveGlyph` | the × on a text-shortcut row | +| `size/stat-l` | `23` | `Typography.sizeStatL` | the next size down when the stats would not fit | +| `size/stat-m` | `20` | `Typography.sizeStatM` | and the next | +| `size/stat-s` | `18` | `Typography.sizeStatS` | the smallest a stat is drawn | +| `size/stat-xl` | `26` | `Typography.sizeStatXl` | a stat's serif number, at the largest size that fits | | `size/tab-icon` | `18` | `Typography.sizeTabIcon` | a tab's icon | | `size/tab-label` | `11` | `Typography.sizeTabLabel` | a tab's mono label | | `size/term` | `17` | `Typography.sizeTerm` | the key-term field | | `size/title` | `34` | `Typography.sizeTitle` | the app's serif headline | +| `size/typing-clock` | `64` | `Typography.sizeTypingClock` | the typing test's countdown | +| `size/typing-field` | `22` | `Typography.sizeTypingField` | what you've typed | +| `size/typing-live` | `28` | `Typography.sizeTypingLive` | its live words per minute | +| `size/typing-rank` | `28` | `Typography.sizeTypingRank` | the result's rank, in the serif | +| `size/typing-result` | `96` | `Typography.sizeTypingResult` | the result's words per minute | +| `size/typing-unit` | `16` | `Typography.sizeTypingUnit` | the result's WPM | +| `size/typing-words` | `24` | `Typography.sizeTypingWords` | the words to type | | `tracking/cta` | `1.4` | `Typography.trackingCta` | uppercase mono at 14: the brand's E2 tracking | | `tracking/eyebrow` | `1.2` | `Typography.trackingEyebrow` | uppercase mono at 12: the brand's E1 tracking | | `weight/glyph` | `medium` | `Typography.weightGlyph` | — | @@ -545,6 +556,7 @@ pins the derivation. | `app/section-gap` | `24` | `Metrics.appSectionGap` | between the home screen's sections | | `app/setup-number-width` | `24` | `Metrics.appSetupNumberWidth` | the setup steps' mono numerals | | `app/stack-gap` | `12` | `Metrics.appStackGap` | between a section's eyebrow and its content, and between cards | +| `app/stat-gap` | `16` | `Metrics.appStatGap` | between the stats in the card, and between its rows | | `app/tab-gap` | `4` | `Metrics.appTabGap` | between a tab's icon and its label | | `app/tab-height` | `52` | `Metrics.appTabHeight` | a tab in the brand tab bar: its icon over its mono label | | `card/border` | `1` | `Metrics.cardBorder` | the card's hairline | @@ -616,6 +628,10 @@ pins the derivation. | `ribs/rest-height` | `16` | `Metrics.ribsRestHeight` | — | | `ribs/rest-width` | `40` | `Metrics.ribsRestWidth` | the ribs asleep: the wave's own bars at rest | | `row/gap` | `11` | `Metrics.rowGap` | between rows, the iPhone's | +| `stat/part-gap-ratio` | `0.38` | `Metrics.statPartGapRatio` | between a stat's number-and-unit parts, as a fraction of its size | +| `stat/tracking-ratio` | `-0.03` | `Metrics.statTrackingRatio` | a stat number's tracking, as a fraction of its size | +| `stat/unit-gap-ratio` | `0.15` | `Metrics.statUnitGapRatio` | between a number and its unit, as a fraction of its size | +| `stat/unit-ratio` | `0.46` | `Metrics.statUnitRatio` | a stat's mono unit (hr, min, sec) as a fraction of its number's size | | `streak/arm` | `1.4` | `Metrics.streakArm` | the landing flash's vertical arm, as a fraction of the box's height | | `streak/height` | `2` | `Metrics.streakHeight` | the light streak while recording | | `streak/line` | `1` | `Metrics.streakLine` | the hairline at rest | @@ -629,6 +645,12 @@ pins the derivation. | `term/inset` | `2` | `Metrics.termInset` | the field row's side inset | | `term/pad` | `14` | `Metrics.termPad` | the field's side padding | | `term/radius` | `8` | `Metrics.termRadius` | the key-term field: the brand's input corners (radius/input) | +| `typing/field-border` | `2` | `Metrics.typingFieldBorder` | the typing field's border, orange once a word goes wrong | +| `typing/field-pad` | `12` | `Metrics.typingFieldPad` | inside the typing field | +| `typing/line-spacing` | `8` | `Metrics.typingLineSpacing` | between the lines of words to type | +| `typing/pulse` | `1.06` | `Metrics.typingPulse` | the countdown's thump in the last five seconds | +| `typing/result-gap` | `20` | `Metrics.typingResultGap` | between the result's blocks | +| `typing/words-height` | `180` | `Metrics.typingWordsHeight` | the least room the words take, so the field below stays put | | `voice/bar-height` | `32` | `Metrics.voiceBarHeight` | — | | `voice/bar-width` | `160` | `Metrics.voiceBarWidth` | the voice element's box in the voice bar — the most it takes (the ribs awake, the streak); the + sits beside what shows | | `voice/home-height` | `112` | `Metrics.voiceHomeHeight` | — | @@ -671,6 +693,8 @@ house curve (`ease/signature`, `cubic-bezier(0.22, 1, 0.36, 1)`). | `sheen-working` | `1.6` | `Motion.sheenWorking` | and while something is happening: the ring's cadence | | `state-fade` | `0.5` | `Motion.stateFade` | every other change on the key: the ring, a glyph, the dimming | | `term-swap` | `0.4` | `Motion.termSwap` | the voice bar becoming the field | +| `typing-flash` | `0.15` | `Motion.typingFlash` | the typing field's border turning, and the countdown's thump | +| `typing-tick` | `0.1` | `Motion.typingTick` | how often the typing test's clock and live speed redraw | | `wave-fade` | `0.7` | `Motion.waveFade` | the orb and the wave crossing, either way | @@ -701,9 +725,22 @@ accent over its replacement, × to remove, between hairlines; two inputs and `ADD SHORTCUT` (re-adding a phrase replaces it). Brand buttons fade while disabled (`opacity/disabled`). -**Home** (`HomeView`, `HomeHero`, `SetupCard`, `StyleChips`, `RecentSection`, -`HomeStatus` for the words): the setup card only while something is missing, -its three steps numbered `01 02 03` in mono; the hero — `STATUS`, the +**First run** (`RootView`, `SetupView`, `SetupSteps`, `SetupProgress`): in +place of the tabs until Blurt is set up — the wordmark, a serif headline, and +`SET UP BLURT` in a card, its three steps numbered `01 02 03` in mono (a check +once done): the API key inline (a secure field, `CONTINUE`, `GET A FREE KEY`, +and where the key and the audio go), the microphone, the keyboard. Without a +key it can't be left; with one, `START DICTATING` goes to the tabs and the rest +can wait. Afterwards the steps live in Settings › Setup, folded to "Blurt is +set up ✓ · Show steps" while everything is done and open while anything isn't. +Whoever was already set up skips it. + +**Home** (`HomeView`, `HomeHero`, `StyleChips`, `RecentSection`, +`HomeStatus` for the words; `StatsCard`): the lifetime stats — `DICTATIONS`, +`WORDS`, `TIME SAVED` (against 36 wpm, the phone average, or the easter egg's +result) as serif numbers with mono units, one row until time saved reaches an +hour and then two, at the largest of four sizes that fits (reset in Settings › +Data); the hero — `STATUS`, the headline (Not listening · Ready to dictate · Connecting… · Listening… · Transcribing… · Pasted · Copied · the error), the line under it, the voice element at home size, `START LISTENING` (refused without a key, with a line @@ -719,6 +756,15 @@ About (with AssemblyAI's privacy policy). The theme picker shows each theme's two faces side by side. Styles, the import sheet, the key entry and the consent sheet share the form's chrome (`brandForm()`). +**The easter egg** (`TypingTestView`, `TypingTest`, `TypingSpeed`): a quiet +"How fast can you type?" link at the foot of Settings opens a 30-second sprint +over common words, full screen — a mono countdown that turns orange and thumps +through the last five seconds (no thump under Reduce Motion), live WPM in the +accent, the current word underlined, wrong words struck through in orange, +the field's border orange once a word goes wrong. The result: WPM, a rank, +accuracy, the best, and talking's 150 wpm against yours; the latest result +becomes time saved's typing speed ("Using your N wpm · Reset to average"). + **Consent** (`ConsentView`, `AIConsent`): before the listening window first opens, a sheet names AssemblyAI, lists what is sent (the voice while dictating, the text before the cursor, recent dictations, key terms with diff --git a/App/BlurtiOS/Design/tokens.json b/App/BlurtiOS/Design/tokens.json index d4a90598..9e677fe8 100644 --- a/App/BlurtiOS/Design/tokens.json +++ b/App/BlurtiOS/Design/tokens.json @@ -772,6 +772,50 @@ "app/row-pad-y": { "value": 8, "use": "a list row's top and bottom inside a card" + }, + "stat/unit-ratio": { + "value": 0.46, + "use": "a stat's mono unit (hr, min, sec) as a fraction of its number's size" + }, + "stat/part-gap-ratio": { + "value": 0.38, + "use": "between a stat's number-and-unit parts, as a fraction of its size" + }, + "stat/unit-gap-ratio": { + "value": 0.15, + "use": "between a number and its unit, as a fraction of its size" + }, + "stat/tracking-ratio": { + "value": -0.03, + "use": "a stat number's tracking, as a fraction of its size" + }, + "app/stat-gap": { + "value": 16, + "use": "between the stats in the card, and between its rows" + }, + "typing/line-spacing": { + "value": 8, + "use": "between the lines of words to type" + }, + "typing/words-height": { + "value": 180, + "use": "the least room the words take, so the field below stays put" + }, + "typing/field-pad": { + "value": 12, + "use": "inside the typing field" + }, + "typing/field-border": { + "value": 2, + "use": "the typing field's border, orange once a word goes wrong" + }, + "typing/pulse": { + "value": 1.06, + "use": "the countdown's thump in the last five seconds" + }, + "typing/result-gap": { + "value": 20, + "use": "between the result's blocks" } }, "type": { @@ -867,6 +911,50 @@ "size/remove-glyph": { "value": 12, "use": "the × on a text-shortcut row" + }, + "size/stat-xl": { + "value": 26, + "use": "a stat's serif number, at the largest size that fits" + }, + "size/stat-l": { + "value": 23, + "use": "the next size down when the stats would not fit" + }, + "size/stat-m": { + "value": 20, + "use": "and the next" + }, + "size/stat-s": { + "value": 18, + "use": "the smallest a stat is drawn" + }, + "size/typing-clock": { + "value": 64, + "use": "the typing test's countdown" + }, + "size/typing-live": { + "value": 28, + "use": "its live words per minute" + }, + "size/typing-words": { + "value": 24, + "use": "the words to type" + }, + "size/typing-field": { + "value": 22, + "use": "what you've typed" + }, + "size/typing-result": { + "value": 96, + "use": "the result's words per minute" + }, + "size/typing-unit": { + "value": 16, + "use": "the result's WPM" + }, + "size/typing-rank": { + "value": 28, + "use": "the result's rank, in the serif" } }, "motion": { @@ -933,6 +1021,14 @@ "glint-life": { "value": 0.5, "use": "a facet's flash while recording" + }, + "typing-tick": { + "value": 0.1, + "use": "how often the typing test's clock and live speed redraw" + }, + "typing-flash": { + "value": 0.15, + "use": "the typing field's border turning, and the countdown's thump" } } } From 1c672976271379f87d917cc69ea612dbe5a83a57 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:24:38 +0000 Subject: [PATCH 9/9] Import AssemblyAI in DictationSessionHandBackTests TextShortcut moved from BlurtEngine to the AssemblyAI module in #231; this test (added on main in #243) still only imported BlurtEngine. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01RBXt5MoXHUDypywez9xZR3 --- Tests/BlurtEngineTests/DictationSessionHandBackTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/BlurtEngineTests/DictationSessionHandBackTests.swift b/Tests/BlurtEngineTests/DictationSessionHandBackTests.swift index 79611a66..734b8c77 100644 --- a/Tests/BlurtEngineTests/DictationSessionHandBackTests.swift +++ b/Tests/BlurtEngineTests/DictationSessionHandBackTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing +@testable import AssemblyAI @testable import BlurtEngine /// A hand-back press (`press(handingBack: true)`, read-aloud's "ask about the