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/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 cd91136e..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` — @@ -189,7 +189,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 f5a6c03b..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,11 +110,13 @@ 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 - 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, @@ -418,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. | @@ -428,7 +432,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. | @@ -693,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. @@ -952,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 @@ -973,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 @@ -982,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 @@ -1012,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/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/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/BlurtiOS/Sources/AppChrome.swift b/App/BlurtiOS/BlurtDesign/Chrome.swift similarity index 56% rename from App/BlurtiOS/BlurtiOS/Sources/AppChrome.swift rename to App/BlurtiOS/BlurtDesign/Chrome.swift index bb2e5f15..5c82212c 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) @@ -107,16 +90,53 @@ 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) } } -/// The lowercase wordmark from the Mac's ready screen, tinted with the accent -/// (`blurt-ready-logo.png`, shared from `Design/brand`). -struct Wordmark: View { +/// 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 +/// 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/BlurtDesign/DesignTokens.swift b/App/BlurtiOS/BlurtDesign/DesignTokens.swift new file mode 100644 index 00000000..faebbb9a --- /dev/null +++ b/App/BlurtiOS/BlurtDesign/DesignTokens.swift @@ -0,0 +1,856 @@ +// Generated by scripts/design-tokens.swift from App/BlurtiOS/Design/tokens.json — do not edit. +// Regenerate with scripts/design-sync.sh; scripts/check.sh fails on drift. +// periphery:ignore:all +// swiftlint:disable file_length +import SwiftUI + +// swiftlint:disable type_body_length +/// The design's colours and numbers, from their one source +/// (`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`). +/// `package`: BlurtDesign's, read by the app, the keyboard and the tests. +package enum DesignTokens { + package enum Brand { + /// The iPhone keyboard's dark key. + package nonisolated static let appleDarkKey: Color = Color(hex: 0x6B6B6B) + /// The iPhone keyboard's dark modifier key. + package nonisolated static let appleDarkModifier: Color = Color(hex: 0x464646) + /// The iPhone keyboard's dark surface. + package nonisolated static let appleDarkSurface: Color = Color(hex: 0x2B2B2B) + /// The iPhone keyboard's light modifier key. + package nonisolated static let appleLightModifier: Color = Color(hex: 0xADB3BC) + /// The iPhone keyboard's light surface. + 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. + package nonisolated static let card: Color = Color(hex: 0xEBE8E8) + /// The card's hairline; the paper theme's modifier key. + package nonisolated static let cardBorder: Color = Color(hex: 0xDEDBDB) + /// AssemblyAI's primary violet: the drop, never a key or a surface. + package nonisolated static let cobolt: Color = Color(hex: 0x3923C7) + /// The design system's UI-and-code green fill (unused on the phone yet). + package nonisolated static let green100: Color = Color(hex: 0xCCE4D5) + /// The design system's UI-and-code green highlight (unused on the phone yet). + package nonisolated static let green200: Color = Color(hex: 0x99C8AC) + /// The brand hue lifted for dark chrome: the meter, the ring, the wave on ink. + package nonisolated static let green400: Color = Color(hex: 0x67AD82) + /// The wordmark green, light chrome (the app's accent in light). + package nonisolated static let green700: Color = Color(hex: 0x01762F) + /// The pale green the orb lightens into. + package nonisolated static let greenMist: Color = Color(hex: 0xDBF5E6) + /// The brand ink: the Mac pill's body, the ink theme's surface. + package nonisolated static let ink: Color = Color(hex: 0x1D1B16) + /// The Mac's dark card border. + package nonisolated static let ink600: Color = Color(hex: 0x3A362F) + /// An ordinary key on ink, one step up. + package nonisolated static let ink700: Color = Color(hex: 0x33302A) + /// A modifier key on ink; the Mac's dark card fill. + package nonisolated static let ink800: Color = Color(hex: 0x26231E) + /// A key on the system's dark keyboard material (measured, iOS 27): the container case. + package nonisolated static let materialDarkKey: Color = Color(hex: 0x575757) + /// A modifier key on the system's dark material. + package nonisolated static let materialDarkModifier: Color = Color(hex: 0x474747) + /// The system's dark keyboard material (measured, iOS 27). + 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. + package nonisolated static let materialLightSurface: Color = Color(hex: 0xE2E3E8) + /// Black-100: the lightest warm grey. + package nonisolated static let neutral300: Color = Color(hex: 0xD2D1D0) + /// Black-200: mono labels on the dark face. + package nonisolated static let neutral500: Color = Color(hex: 0xA5A4A2) + /// Black-300: muted text and mono labels on the light face. + package nonisolated static let neutral700: Color = Color(hex: 0x777673) + /// Black-400: body text on the light face. + package nonisolated static let neutral900: Color = Color(hex: 0x4A4945) + /// The error word and ring, cancel, the Full Access note; never a red body. + package nonisolated static let orange: Color = Color(hex: 0xE67F36) + /// Neutral-100: the light face's surface. + package nonisolated static let paper200: Color = Color(hex: 0xECEBE5) + /// Neutral-200: a modifier key on the light face. + package nonisolated static let paper300: Color = Color(hex: 0xDAD7CB) + /// Neutral-300: hairlines and field borders on the light face. + package nonisolated static let paper400: Color = Color(hex: 0xC7C3B2) + /// The design system's page: the app's light ground. + package nonisolated static let paperPage: Color = Color(hex: 0xFDFCF8) + /// The warm off-white one step in: light cards, dark legends. + package nonisolated static let paperTint: Color = Color(hex: 0xF5F3EB) + /// The orb gradient's upper violet stop. + package nonisolated static let violetIris: Color = Color(hex: 0x887BDD) + /// The orb's lightest violet: its top and the gradient's ends. + package nonisolated static let violetLavender: Color = Color(hex: 0xD7D3F4) + /// The orb's mid violet: the drop's halo. + package nonisolated static let violetPeriwinkle: Color = Color(hex: 0xB0A7E9) + /// Key legends on ink: warm white on warm ink. + package nonisolated static let warmWhite: Color = Color(hex: 0xF2EEE6) + package nonisolated static let white: Color = Color(hex: 0xFFFFFF) + } + + package enum Themes { + /// `brand.material/dark-key`. + package nonisolated static let darkContainerKey: Color = Brand.materialDarkKey + /// `brand.material/dark-modifier`. + package nonisolated static let darkContainerKeyModifier: Color = Brand.materialDarkModifier + /// `brand.ink/700`. + package nonisolated static let darkField: Color = Brand.ink700 + /// `brand.ink/600`. + package nonisolated static let darkFieldBorder: Color = Brand.ink600 + /// `brand.ink/700`. + package nonisolated static let darkKey: Color = Brand.ink700 + /// `brand.ink/800`. + package nonisolated static let darkKeyModifier: Color = Brand.ink800 + /// `brand.paper/tint`. + package nonisolated static let darkLegend: Color = Brand.paperTint + /// `brand.neutral/500`. + package nonisolated static let darkLegendSecondary: Color = Brand.neutral500 + /// `brand.material/dark-surface`. + package nonisolated static let darkMaterial: Color = Brand.materialDarkSurface + /// `brand.orange`. + package nonisolated static let darkNotice: Color = Brand.orange + /// `brand.ink/700`. + package nonisolated static let darkPopup: Color = Brand.ink700 + /// `brand.green/400`. + package nonisolated static let darkSignal: Color = Brand.green400 + /// `brand.ink`. + package nonisolated static let darkSurface: Color = Brand.ink + /// `brand.white`. + package nonisolated static let lightContainerKey: Color = Brand.white + /// `brand.paper/300`. + package nonisolated static let lightContainerKeyModifier: Color = Brand.paper300 + /// `brand.white`. + package nonisolated static let lightField: Color = Brand.white + /// `brand.paper/400`. + package nonisolated static let lightFieldBorder: Color = Brand.paper400 + /// `brand.white`. + package nonisolated static let lightKey: Color = Brand.white + /// `brand.paper/300`. + package nonisolated static let lightKeyModifier: Color = Brand.paper300 + /// `brand.ink`. + package nonisolated static let lightLegend: Color = Brand.ink + /// `brand.neutral/700`. + package nonisolated static let lightLegendSecondary: Color = Brand.neutral700 + /// `brand.material/light-surface`. + package nonisolated static let lightMaterial: Color = Brand.materialLightSurface + /// `brand.orange`. + package nonisolated static let lightNotice: Color = Brand.orange + /// `brand.white`. + package nonisolated static let lightPopup: Color = Brand.white + /// `brand.green/700`. + package nonisolated static let lightSignal: Color = Brand.green700 + /// `brand.paper/200`. + package nonisolated static let lightSurface: Color = Brand.paper200 + } + + package enum Keyboard { + /// The catalog's AccentColor in dark. + 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. + package nonisolated static let appCardBorderDark: Color = Brand.ink600 + /// The catalog's CardBorder in light. + package nonisolated static let appCardBorderLight: Color = Brand.paper400 + /// The catalog's CardFill in dark. + package nonisolated static let appCardFillDark: Color = Brand.ink800 + /// The catalog's CardFill in light. + 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. + package nonisolated static let appCtaTextDark: Color = Brand.ink + /// The catalog's CTAText in light. + package nonisolated static let appCtaTextLight: Color = Brand.white + /// The catalog's Muted in dark. + package nonisolated static let appMutedDark: Color = Brand.neutral500 + /// The catalog's Muted in light: eyebrows, captions, secondary text. + package nonisolated static let appMutedLight: Color = Brand.neutral700 + /// The catalog's Page in dark: ink, the website's ground. + package nonisolated static let appPageDark: Color = Brand.ink + /// The catalog's Page in light: the design system's page. + package nonisolated static let appPageLight: Color = Brand.paperPage + /// The catalog's Text in dark. + package nonisolated static let appTextDark: Color = Brand.paperTint + /// The catalog's Text in light. + package nonisolated static let appTextLight: Color = Brand.ink + /// The panel's cancel ×. + package nonisolated static let kbCancel: Color = Brand.orange + /// The key-term field. + package nonisolated static let kbField: Color = Themes.darkField + /// The field's hairline. + package nonisolated static let kbFieldBorder: Color = Themes.darkFieldBorder + /// The one line of words the keyboard ever shows. + package nonisolated static let kbFullAccessNote: Color = Brand.orange + /// An ordinary key. + package nonisolated static let kbKey: Color = Themes.darkKey + /// Shift, delete, globe, return, 123, cancel. + package nonisolated static let kbKeyModifier: Color = Themes.darkKeyModifier + /// Key legends and bare glyphs. + package nonisolated static let kbLegend: Color = Themes.darkLegend + /// The mono word labels (123, ABC, return, space) and the + at rest. + package nonisolated static let kbLegendSecondary: Color = Themes.darkLegendSecondary + /// The solid ring and glyph for an error. + package nonisolated static let kbNoticeError: Color = Brand.orange + /// The letter pop-up. + package nonisolated static let kbPopup: Color = Themes.darkPopup + /// The wave, the caret, the saved check. + package nonisolated static let kbSignal: Color = Themes.darkSignal + /// The design face's surface (Swift reads the palette). + package nonisolated static let kbSurface: Color = Themes.darkSurface + } + + package enum Metrics { + /// Between the chip's glyph and the word. + 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. + package nonisolated static let addtermChipMinWidth: CGFloat = 96 + /// The selected-word chip's side padding. + package nonisolated static let addtermChipPad: CGFloat = 10 + /// The cancel × from the panel's far corner when the mic is at an edge. + package nonisolated static let addtermInset: CGFloat = 6 + /// The brand's button. + package nonisolated static let appButtonHeight: CGFloat = 40 + /// Its side padding. + package nonisolated static let appButtonPad: CGFloat = 12 + /// Inside a card. + package nonisolated static let appCardPad: CGFloat = 16 + /// Between chips. + package nonisolated static let appChipGap: CGFloat = 8 + /// A style chip's side padding. + package nonisolated static let appChipPadX: CGFloat = 12 + /// And its top and bottom. + package nonisolated static let appChipPadY: CGFloat = 8 + /// The home header: wordmark and gear. + package nonisolated static let appHeaderHeight: CGFloat = 44 + /// Between the hero's pieces. + package nonisolated static let appHeroGap: CGFloat = 14 + /// Inside the hero card. + 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 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. + package nonisolated static let appTabHeight: CGFloat = 52 + /// The card's hairline. + package nonisolated static let cardBorder: CGFloat = 1 + /// The app's cards (radius/card). + package nonisolated static let cardRadius: CGFloat = 12 + package nonisolated static let caretHeight: CGFloat = 20 + /// The caret's gap from the text. + 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. + package nonisolated static let glyphHit: CGFloat = 44 + package nonisolated static let grilleBarHeight: CGFloat = 28 + /// The grille in the voice bar. + package nonisolated static let grilleBarWidth: CGFloat = 64 + /// A dot's diameter. + package nonisolated static let grilleDot: CGFloat = 2 + /// The cross glint's vertical arm, as a fraction of the grille's height. + package nonisolated static let grilleGlintArm: CGFloat = 1.6 + /// The glint's arms. + package nonisolated static let grilleGlintWidth: CGFloat = 1 + package nonisolated static let grilleHomeHeight: CGFloat = 108 + /// The grille on the home screen. + package nonisolated static let grilleHomeWidth: CGFloat = 240 + package nonisolated static let grillePanelHeight: CGFloat = 72 + /// The grille in the panel. + package nonisolated static let grillePanelWidth: CGFloat = 160 + /// The dot lattice: one dot every. + package nonisolated static let grillePitch: CGFloat = 4 + /// The sheen's band, as a fraction of the width. + package nonisolated static let grilleSheenWidth: CGFloat = 0.35 + /// 123 and the globe at 402 pt: the iPhone's. + package nonisolated static let keyAbcWidth402: CGFloat = 43.333 + /// Between keys, the iPhone's. + package nonisolated static let keyGap: CGFloat = 6 + /// Every key: the iPhone's cap height (apple-geometry.json). + package nonisolated static let keyHeight: CGFloat = 43 + /// A letter key at 402 pt: (width − 2 × margin/side − 9 × key/gap) / 10. + package nonisolated static let keyLetterWidth402: CGFloat = 33.5 + /// A key that isn't given a width: the iPhone's 123 key at 402 pt. + package nonisolated static let keyMinWidth: CGFloat = 43.333 + /// A legend's side padding on a min-width key. + package nonisolated static let keyPad: CGFloat = 4 + /// Flat keys, one colour at this corner: the iPhone's (fitted 8.17). + package nonisolated static let keyRadius: CGFloat = 8 + /// The width the @402 tokens were measured at (iPhone 18 Pro); widths scale from it. + package nonisolated static let keyReferenceWidth: CGFloat = 402 + /// Return at 402 pt: two 123 keys and a gap. + package nonisolated static let keyReturnWidth402: CGFloat = 92.667 + /// Shift and delete stand this far from the letters: the iPhone's. + 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). + package nonisolated static let keySideWidth402: CGFloat = 45.583 + /// Space at 402 pt beside 123, the globe and return: what is left (measured 191.333). + package nonisolated static let keySpaceWidth402: CGFloat = 191.667 + /// Margin/vertical + voicebar/height + 4 × key/height + 3 × row/gap + margin/bottom-keys. + package nonisolated static let layoutFull: CGFloat = 270 + /// Chosen: room for the 96 orb over one key row. + package nonisolated static let layoutPanel: CGFloat = 216 + /// 2 × margin/vertical + voicebar/height. + package nonisolated static let layoutSlim: CGFloat = 60 + /// Under a bottom row of keys (the full keyboard, the panel): the iPhone's. + package nonisolated static let marginBottomKeys: CGFloat = 13 + /// At the keyboard's sides: the iPhone's cap margin, (402 − 10 letters − 9 gaps) / 2. + 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; 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 + /// Film grain over the dark face: matte, the brand's texture. + package nonisolated static let opacityGrainDark: CGFloat = 0.14 + /// Film grain over the light face: paper, lighter. + package nonisolated static let opacityGrainLight: CGFloat = 0.08 + /// The dots at rest, over the key colour. + 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. + package nonisolated static let opacityLegend: CGFloat = 1 + /// The + at rest. + 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. + package nonisolated static let opacityOff: CGFloat = 0.6 + /// The field's placeholder. + package nonisolated static let opacityPlaceholder: CGFloat = 0.4 + package nonisolated static let opacityPopupShadow: CGFloat = 0.12 + /// A key lightens this much while pressed. + package nonisolated static let opacityPressBrighten: CGFloat = 0.15 + /// A brand button while pressed: a colour change, no lift. + package nonisolated static let opacityPressed: CGFloat = 0.85 + /// The light passing over the element at rest: chrome catching light. + package nonisolated static let opacitySheen: CGFloat = 0.22 + /// A soft darkening toward the surface's edges; 0 is none. + package nonisolated static let opacitySurfaceVignette: CGFloat = 0 + /// The field's ×. + 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. + package nonisolated static let panelSpacing: CGFloat = 8 + /// The theme card draws the keyboard at this width. + package nonisolated static let pickerPreviewWidth: CGFloat = 393 + /// The theme card's preview corners. + package nonisolated static let pickerRadius: CGFloat = 10 + /// Then scales it to fit two across. + package nonisolated static let pickerScale: CGFloat = 0.42 + /// The letter pop-up is the key width plus this. + 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. + package nonisolated static let popupOffset: CGFloat = 50 + package nonisolated static let popupRadius: CGFloat = 10 + /// The one thing that floats. + 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. + package nonisolated static let radiusButton: CGFloat = 4 + /// The brand's cards. + package nonisolated static let radiusCard: CGFloat = 12 + /// The brand's featured cards. + package nonisolated static let radiusHero: CGFloat = 16 + /// The brand's inputs: the key-term field. + package nonisolated static let radiusInput: CGFloat = 8 + package nonisolated static let ribsRestHeight: CGFloat = 16 + /// The ribs asleep: the wave's own bars at rest. + 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. + package nonisolated static let streakHeight: CGFloat = 2 + /// The hairline at rest. + package nonisolated static let streakLine: CGFloat = 1 + /// The bright point at the centre. + 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. + package nonisolated static let surfacePaint: CGFloat = 0 + /// Where it is full. + package nonisolated static let surfaceVignetteEnd: CGFloat = 0.75 + /// Where the vignette begins, as a fraction of the keyboard's width. + package nonisolated static let surfaceVignetteStart: CGFloat = 0.3 + /// The field's hairline. + package nonisolated static let termBorder: CGFloat = 1 + /// × · field · ✓. + package nonisolated static let termGap: CGFloat = 8 + /// The key-term field's capsule. + package nonisolated static let termHeight: CGFloat = 36 + /// The field row's side inset. + package nonisolated static let termInset: CGFloat = 2 + /// The field's side padding. + 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 + package nonisolated static let voiceHomeHeight: CGFloat = 112 + /// The box on the home screen. + package nonisolated static let voiceHomeWidth: CGFloat = 280 + package nonisolated static let voicePanelHeight: CGFloat = 88 + /// The box in the panel. + package nonisolated static let voicePanelWidth: CGFloat = 300 + /// The element while pressed. + 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. + package nonisolated static let voicebarAddtermClearance: CGFloat = 6 + /// The voice row, where the system puts its suggestion bar. + package nonisolated static let voicebarHeight: CGFloat = 44 + /// Between the orb and the Full Access note. + package nonisolated static let voicebarNoteGap: CGFloat = 10 + /// A wave bar's width. + package nonisolated static let waveBar: CGFloat = 2 + package nonisolated static let waveBarHeight: CGFloat = 24 + /// Between wave bars. + 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 + } + + package enum Typography { + /// The clipboard and exclamation glyphs, as a fraction of the voice element's height. + package nonisolated static let ratioVoiceGlyph: CGFloat = 0.34 + /// The app's body text. + package nonisolated static let sizeBody: CGFloat = 16 + /// The app's small text. + package nonisolated static let sizeCaption: CGFloat = 14 + /// A mono button label in the app (E2). + package nonisolated static let sizeCta: CGFloat = 14 + /// A mono eyebrow in the app (E1). + package nonisolated static let sizeEyebrow: CGFloat = 12 + /// The +, × and ✓. + package nonisolated static let sizeGlyph: CGFloat = 17 + /// The mono word labels on keys: 123, ABC, #+=, space, the return label. + package nonisolated static let sizeLabel: CGFloat = 12 + /// Every other key. + 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 + /// 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. + package nonisolated static let sizeTabLabel: CGFloat = 11 + /// The key-term field. + 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. + 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 + } + + package enum Motion { + /// The caret's blink. + package nonisolated static let caret: Double = 0.5 + /// Holding the chip opens the field instead of adding; longer than a firm tap. + package nonisolated static let chipHold: Double = 0.6 + /// A colour or fill changing state: the brand's transition. + package nonisolated static let colour: Double = 0.2 + /// The house curve for everything that moves; nothing springs. + 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. + package nonisolated static let flip: Double = 0.25 + /// A facet's flash while recording. + package nonisolated static let glintLife: Double = 0.5 + /// The keyboard resizing. + package nonisolated static let heightChange: Double = 0.25 + /// A key lighting. + package nonisolated static let keyPress: Double = 0.08 + /// The glint when the words land. + package nonisolated static let landing: Double = 0.6 + /// The orb's press; the one thing that answers at once. + package nonisolated static let press: Double = 0.1 + /// One turn of the ring, the Mac's cadence. + package nonisolated static let ringPeriod: Double = 1.6 + /// One pass of the light over the element at rest. + package nonisolated static let sheen: Double = 4 + /// And while something is happening: the ring's cadence. + package nonisolated static let sheenWorking: Double = 1.6 + /// Every other change on the key: the ring, a glyph, the dimming. + 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 + } + + 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`). + package nonisolated static let manifest: [String: String] = [ + "brand.apple/dark-key": "#6B6B6B", + "brand.apple/dark-modifier": "#464646", + "brand.apple/dark-surface": "#2B2B2B", + "brand.apple/light-modifier": "#ADB3BC", + "brand.apple/light-surface": "#D1D5DB", + "brand.black": "#000000", + "brand.card": "#EBE8E8", + "brand.card-border": "#DEDBDB", + "brand.cobolt": "#3923C7", + "brand.green/100": "#CCE4D5", + "brand.green/200": "#99C8AC", + "brand.green/400": "#67AD82", + "brand.green/700": "#01762F", + "brand.green/mist": "#DBF5E6", + "brand.ink": "#1D1B16", + "brand.ink/600": "#3A362F", + "brand.ink/700": "#33302A", + "brand.ink/800": "#26231E", + "brand.material/dark-key": "#575757", + "brand.material/dark-modifier": "#474747", + "brand.material/dark-surface": "#373737", + "brand.material/light-surface": "#E2E3E8", + "brand.neutral/300": "#D2D1D0", + "brand.neutral/500": "#A5A4A2", + "brand.neutral/700": "#777673", + "brand.neutral/900": "#4A4945", + "brand.orange": "#E67F36", + "brand.paper/200": "#ECEBE5", + "brand.paper/300": "#DAD7CB", + "brand.paper/400": "#C7C3B2", + "brand.paper/page": "#FDFCF8", + "brand.paper/tint": "#F5F3EB", + "brand.violet/iris": "#887BDD", + "brand.violet/lavender": "#D7D3F4", + "brand.violet/periwinkle": "#B0A7E9", + "brand.warm-white": "#F2EEE6", + "brand.white": "#FFFFFF", + "themes.dark/container-key": "#575757", + "themes.dark/container-key-modifier": "#474747", + "themes.dark/field": "#33302A", + "themes.dark/field-border": "#3A362F", + "themes.dark/key": "#33302A", + "themes.dark/key-modifier": "#26231E", + "themes.dark/legend": "#F5F3EB", + "themes.dark/legend-secondary": "#A5A4A2", + "themes.dark/material": "#373737", + "themes.dark/notice": "#E67F36", + "themes.dark/popup": "#33302A", + "themes.dark/signal": "#67AD82", + "themes.dark/surface": "#1D1B16", + "themes.light/container-key": "#FFFFFF", + "themes.light/container-key-modifier": "#DAD7CB", + "themes.light/field": "#FFFFFF", + "themes.light/field-border": "#C7C3B2", + "themes.light/key": "#FFFFFF", + "themes.light/key-modifier": "#DAD7CB", + "themes.light/legend": "#1D1B16", + "themes.light/legend-secondary": "#777673", + "themes.light/material": "#E2E3E8", + "themes.light/notice": "#E67F36", + "themes.light/popup": "#FFFFFF", + "themes.light/signal": "#01762F", + "themes.light/surface": "#ECEBE5", + "keyboard.app/accent-dark": "#67AD82", + "keyboard.app/accent-light": "#01762F", + "keyboard.app/card-border-dark": "#3A362F", + "keyboard.app/card-border-light": "#C7C3B2", + "keyboard.app/card-fill-dark": "#26231E", + "keyboard.app/card-fill-light": "#F5F3EB", + "keyboard.app/cta-dark": "#67AD82", + "keyboard.app/cta-light": "#01762F", + "keyboard.app/cta-text-dark": "#1D1B16", + "keyboard.app/cta-text-light": "#FFFFFF", + "keyboard.app/muted-dark": "#A5A4A2", + "keyboard.app/muted-light": "#777673", + "keyboard.app/page-dark": "#1D1B16", + "keyboard.app/page-light": "#FDFCF8", + "keyboard.app/text-dark": "#F5F3EB", + "keyboard.app/text-light": "#1D1B16", + "keyboard.kb/cancel": "#E67F36", + "keyboard.kb/field": "#33302A", + "keyboard.kb/field-border": "#3A362F", + "keyboard.kb/full-access-note": "#E67F36", + "keyboard.kb/key": "#33302A", + "keyboard.kb/key-modifier": "#26231E", + "keyboard.kb/legend": "#F5F3EB", + "keyboard.kb/legend-secondary": "#A5A4A2", + "keyboard.kb/notice-error": "#E67F36", + "keyboard.kb/popup": "#33302A", + "keyboard.kb/signal": "#67AD82", + "keyboard.kb/surface": "#1D1B16", + "metrics.addterm/chip-gap": "6", + "metrics.addterm/chip-min-width": "96", + "metrics.addterm/chip-pad": "10", + "metrics.addterm/inset": "6", + "metrics.app/button-height": "40", + "metrics.app/button-pad": "12", + "metrics.app/card-pad": "16", + "metrics.app/chip-gap": "8", + "metrics.app/chip-pad-x": "12", + "metrics.app/chip-pad-y": "8", + "metrics.app/header-height": "44", + "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/stat-gap": "16", + "metrics.app/tab-gap": "4", + "metrics.app/tab-height": "52", + "metrics.card/border": "1", + "metrics.card/radius": "12", + "metrics.caret/height": "20", + "metrics.caret/lead": "1", + "metrics.caret/radius": "1", + "metrics.caret/width": "2", + "metrics.glyph/hit": "44", + "metrics.grille/bar-height": "28", + "metrics.grille/bar-width": "64", + "metrics.grille/dot": "2", + "metrics.grille/glint-arm": "1.6", + "metrics.grille/glint-width": "1", + "metrics.grille/home-height": "108", + "metrics.grille/home-width": "240", + "metrics.grille/panel-height": "72", + "metrics.grille/panel-width": "160", + "metrics.grille/pitch": "4", + "metrics.grille/sheen-width": "0.35", + "metrics.key/abc-width-402": "43.333", + "metrics.key/gap": "6", + "metrics.key/height": "43", + "metrics.key/letter-width-402": "33.5", + "metrics.key/min-width": "43.333", + "metrics.key/pad": "4", + "metrics.key/radius": "8", + "metrics.key/reference-width": "402", + "metrics.key/return-width-402": "92.667", + "metrics.key/side-gap": "13.667", + "metrics.key/side-width-402": "45.583", + "metrics.key/space-width-402": "191.667", + "metrics.layout/full": "270", + "metrics.layout/panel": "216", + "metrics.layout/slim": "60", + "metrics.margin/bottom-keys": "13", + "metrics.margin/side": "6.5", + "metrics.margin/vertical": "8", + "metrics.opacity/disabled": "0.4", + "metrics.opacity/glint": "0.95", + "metrics.opacity/grain-dark": "0.14", + "metrics.opacity/grain-light": "0.08", + "metrics.opacity/grille-rest": "0.55", + "metrics.opacity/hairline": "0.5", + "metrics.opacity/legend": "1", + "metrics.opacity/legend-muted": "0.5", + "metrics.opacity/off": "0.6", + "metrics.opacity/placeholder": "0.4", + "metrics.opacity/popup-shadow": "0.12", + "metrics.opacity/press-brighten": "0.15", + "metrics.opacity/pressed": "0.85", + "metrics.opacity/sheen": "0.22", + "metrics.opacity/surface-vignette": "0", + "metrics.opacity/term-cancel": "0.7", + "metrics.panel/spacing": "8", + "metrics.picker/preview-width": "393", + "metrics.picker/radius": "10", + "metrics.picker/scale": "0.42", + "metrics.popup/extra-width": "18", + "metrics.popup/height": "56", + "metrics.popup/offset": "50", + "metrics.popup/radius": "10", + "metrics.popup/shadow-radius": "8", + "metrics.popup/shadow-y": "2", + "metrics.radius/button": "4", + "metrics.radius/card": "12", + "metrics.radius/hero": "16", + "metrics.radius/input": "8", + "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", + "metrics.streak/point": "4", + "metrics.surface/paint": "0", + "metrics.surface/vignette-end": "0.75", + "metrics.surface/vignette-start": "0.3", + "metrics.term/border": "1", + "metrics.term/gap": "8", + "metrics.term/height": "36", + "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", + "metrics.voice/home-width": "280", + "metrics.voice/panel-height": "88", + "metrics.voice/panel-width": "300", + "metrics.voice/press-scale": "0.94", + "metrics.voicebar/addterm-clearance": "6", + "metrics.voicebar/height": "44", + "metrics.voicebar/note-gap": "10", + "metrics.wave/bar": "2", + "metrics.wave/bar-height": "24", + "metrics.wave/gap": "2", + "metrics.wave/home-height": "44", + "metrics.wave/panel-height": "40", + "metrics.wordmark/height": "22", + "type.ratio/voice-glyph": "0.34", + "type.size/body": "16", + "type.size/caption": "14", + "type.size/cta": "14", + "type.size/eyebrow": "12", + "type.size/glyph": "17", + "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/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", + "type.weight/label": "medium", + "type.weight/legend": "medium", + "type.weight/letter": "regular", + "type.weight/popup": "regular", + "type.weight/term": "regular", + "type.weight/voice-glyph": "semibold", + "motion.caret": "0.5", + "motion.chip-hold": "0.6", + "motion.colour": "0.2", + "motion.ease/signature": "0.22,1,0.36,1", + "motion.flip": "0.25", + "motion.glint-life": "0.5", + "motion.height-change": "0.25", + "motion.key-press": "0.08", + "motion.landing": "0.6", + "motion.press": "0.1", + "motion.ring-period": "1.6", + "motion.sheen": "4", + "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", + ] +} +// swiftlint:enable type_body_length 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/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/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/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index a24e3de0..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,8 +74,7 @@ struct BlurtiOSApp: App { } private var home: some View { - HomeView(coordinator: coordinator) - .onOpenURL { coordinator.handle($0) } + RootView(coordinator: coordinator) .task { await start() } } 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/DictationCoordinator.swift b/App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift index c6d7b1bd..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. @@ -204,7 +215,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] { @@ -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/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..55cb1ccf 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/HomeView.swift @@ -1,20 +1,22 @@ import AVFoundation +import BlurtDesign import BlurtEngine import BlurtiOSCore import SwiftUI -/// The app's one screen: 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 Dictate tab: the Blurt landing page on a phone. The wordmark and +/// 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. + 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 @@ -33,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) @@ -50,22 +48,6 @@ struct HomeView: View { .page() .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() } } @@ -81,14 +63,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/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/MainView.swift b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift new file mode 100644 index 00000000..cdb4c0c3 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/MainView.swift @@ -0,0 +1,90 @@ +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 +/// 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/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..a4b89913 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -1,3 +1,5 @@ +import AVFoundation +import BlurtDesign import BlurtEngine import BlurtiOSCore import SwiftUI @@ -5,7 +7,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 @@ -18,17 +20,25 @@ 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 = "" + @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) { @@ -103,24 +161,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.") } } @@ -133,22 +177,41 @@ 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") } } - /// 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/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 9ca3adc9..d53bd720 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SetupCard.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SetupSteps.swift @@ -1,37 +1,29 @@ import AVFoundation -import BlurtEngine +import BlurtDesign 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 { @@ -55,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/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/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/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/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/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/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/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/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/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/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/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/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/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/SharedStateTests.swift b/App/BlurtiOS/BlurtiOSTests/SharedStateTests.swift index 134d6ad8..2d7d7c38 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 @@ -39,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/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/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/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.. -| 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) | @@ -530,12 +545,20 @@ pins the derivation. | `app/hero-gap` | `14` | `Metrics.appHeroGap` | between the hero's pieces | | `app/hero-pad` | `20` | `Metrics.appHeroPad` | inside the hero card | | `app/icon` | `20` | `Metrics.appIcon` | the header's gear, the copy glyph | +| `app/input-pad-y` | `10` | `Metrics.appInputPadY` | an app input's top and bottom (its sides are app/chip-pad-x) | | `app/line-gap` | `6` | `Metrics.appLineGap` | between lines of one thought | | `app/page-pad` | `20` | `Metrics.appPagePad` | the screens' side padding | +| `app/pill-pad-y` | `4` | `Metrics.appPillPadY` | a key-term pill's top and bottom, and its side beside the × | +| `app/pill-remove` | `24` | `Metrics.appPillRemove` | the × on a key-term pill: its tap square | | `app/preview-gap` | `8` | `Metrics.appPreviewGap` | between the theme card's two faces | +| `app/row-pad-y` | `8` | `Metrics.appRowPadY` | a list row's top and bottom inside a card | +| `app/row-remove` | `32` | `Metrics.appRowRemove` | the × on a text-shortcut row: its tap square | | `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 | | `card/radius` | `12` | `Metrics.cardRadius` | the app's cards (radius/card) | | `caret/height` | `20` | `Metrics.caretHeight` | — | @@ -572,7 +595,7 @@ pins the derivation. | `margin/bottom-keys` | `13` | `Metrics.marginBottomKeys` | under a bottom row of keys (the full keyboard, the panel): the iPhone's | | `margin/side` | `6.5` | `Metrics.marginSide` | at the keyboard's sides: the iPhone's cap margin, (402 − 10 letters − 9 gaps) / 2 | | `margin/vertical` | `8` | `Metrics.marginVertical` | the keyboard's top, and the slim bar's bottom | -| `opacity/disabled` | `0.4` | `Metrics.opacityDisabled` | the + without Full Access | +| `opacity/disabled` | `0.4` | `Metrics.opacityDisabled` | the + without Full Access; a brand button that can't be pressed yet | | `opacity/glint` | `0.95` | `Metrics.opacityGlint` | the cross glint when the words land | | `opacity/grain-dark` | `0.14` | `Metrics.opacityGrainDark` | film grain over the dark face: matte, the brand's texture | | `opacity/grain-light` | `0.08` | `Metrics.opacityGrainLight` | film grain over the light face: paper, lighter | @@ -605,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 | @@ -618,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` | — | @@ -660,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 | @@ -676,9 +711,36 @@ row (Cam's rule) and a hairline one beside it, a colour change on press and never a lift. Glyphs are SF Symbols — the platform's — where the web brand uses Material Symbols. -**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 +**Tabs** (`MainView`, `BrandTabBar`): Dictate and Words under the brand tab +bar — each tab's icon over its mono label, the selected one in the accent, a +hairline above. Settings opens from either tab's gear; the consent sheet and a +shared key-term list arrive whichever tab is showing (a list switches to Words). + +**Words** (`WordsView`, `WordsEdit`): the serif `Words` headline and the gear; +two cards. `KEY TERMS`: the terms as pills (full radius, the page's fill, a +hairline, × to remove; `FlowLayout`), an input and `ADD` (commas add several; +duplicates of any case drop; 100 at most, the request's cap), the count and the +contact names that come along, and Share. `TEXT SHORTCUTS`: each phrase in mono +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`). + +**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 @@ -689,12 +751,20 @@ 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 (the API key, and where to get one), +Transcription (enhanced transcripts, output styles), 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()`). +**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 29e2f484..9e677fe8 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}", @@ -484,7 +484,7 @@ }, "opacity/disabled": { "value": 0.4, - "use": "the + without Full Access" + "use": "the + without Full Access; a brand button that can't be pressed yet" }, "opacity/press-brighten": { "value": 0.15, @@ -744,6 +744,78 @@ "surface/paint": { "value": 0, "use": "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" + }, + "app/tab-height": { + "value": 52, + "use": "a tab in the brand tab bar: its icon over its mono label" + }, + "app/tab-gap": { + "value": 4, + "use": "between a tab's icon and its label" + }, + "app/input-pad-y": { + "value": 10, + "use": "an app input's top and bottom (its sides are app/chip-pad-x)" + }, + "app/pill-pad-y": { + "value": 4, + "use": "a key-term pill's top and bottom, and its side beside the ×" + }, + "app/pill-remove": { + "value": 24, + "use": "the × on a key-term pill: its tap square" + }, + "app/row-remove": { + "value": 32, + "use": "the × on a text-shortcut row: its tap square" + }, + "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": { @@ -823,6 +895,66 @@ }, "weight/voice-glyph": { "value": "semibold" + }, + "size/tab-label": { + "value": 11, + "use": "a tab's mono label" + }, + "size/tab-icon": { + "value": 18, + "use": "a tab's icon" + }, + "size/pill-glyph": { + "value": 10, + "use": "the × on a key-term pill" + }, + "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": { @@ -889,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" } } } diff --git a/App/BlurtiOS/README.md b/App/BlurtiOS/README.md index b24bc878..b972a953 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`: @@ -75,7 +76,7 @@ preview hands the model a `ThemeFace`, not a palette — the colours are the UI' ## Unit tests `scripts/ios-test.sh` runs `BlurtiOSTests` on a simulator (CI's `ios-build` job runs it after -the build): the core's logic — the App Group contract (`KeyTermList`, stale snapshots, layouts), +the build): the core's logic — the App Group contract (key terms, stale snapshots, layouts), the keyboard's rules (sentence start, the double space, the term field, return labels, letter rows) against a fake text field, the gate against the app's phases, the Darwin signals, term packs — and what the UI builds on it (palettes, the voice element, the gallery). The engine's 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/Shared/DesignTokens.swift b/App/BlurtiOS/Shared/DesignTokens.swift deleted file mode 100644 index dad8de06..00000000 --- a/App/BlurtiOS/Shared/DesignTokens.swift +++ /dev/null @@ -1,750 +0,0 @@ -// Generated by scripts/design-tokens.swift from App/BlurtiOS/Design/tokens.json — do not edit. -// Regenerate with scripts/design-sync.sh; scripts/check.sh fails on drift. -// periphery:ignore:all -// swiftlint:disable file_length -import SwiftUI - -// swiftlint:disable type_body_length -/// The design's colours and numbers, from their one source -/// (`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 { - /// The iPhone keyboard's dark key. - nonisolated static let appleDarkKey: Color = Color(hex: 0x6B6B6B) - /// The iPhone keyboard's dark modifier key. - nonisolated static let appleDarkModifier: Color = Color(hex: 0x464646) - /// The iPhone keyboard's dark surface. - nonisolated static let appleDarkSurface: Color = Color(hex: 0x2B2B2B) - /// The iPhone keyboard's light modifier key. - 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) - /// The warm card fill; the paper theme's surface. - 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) - /// AssemblyAI's primary violet: the drop, never a key or a surface. - 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) - /// The design system's UI-and-code green highlight (unused on the phone yet). - 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) - /// The wordmark green, light chrome (the app's accent in light). - nonisolated static let green700: Color = Color(hex: 0x01762F) - /// The pale green the orb lightens into. - 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) - /// The Mac's dark card border. - nonisolated static let ink600: Color = Color(hex: 0x3A362F) - /// An ordinary key on ink, one step up. - 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) - /// A key on the system's dark keyboard material (measured, iOS 27): the container case. - 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) - /// The system's dark keyboard material (measured, iOS 27). - 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) - /// Black-100: the lightest warm grey. - nonisolated static let neutral300: Color = Color(hex: 0xD2D1D0) - /// Black-200: mono labels on the dark face. - 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) - /// Black-400: body text on the light face. - 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) - /// Neutral-100: the light face's surface. - 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) - /// Neutral-300: hairlines and field borders on the light face. - 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) - /// The warm off-white one step in: light cards, dark legends. - nonisolated static let paperTint: Color = Color(hex: 0xF5F3EB) - /// The orb gradient's upper violet stop. - 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) - /// The orb's mid violet: the drop's halo. - 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) - } - - enum Themes { - /// `brand.material/dark-key`. - nonisolated static let darkContainerKey: Color = Brand.materialDarkKey - /// `brand.material/dark-modifier`. - nonisolated static let darkContainerKeyModifier: Color = Brand.materialDarkModifier - /// `brand.ink/700`. - nonisolated static let darkField: Color = Brand.ink700 - /// `brand.ink/600`. - nonisolated static let darkFieldBorder: Color = Brand.ink600 - /// `brand.ink/700`. - nonisolated static let darkKey: Color = Brand.ink700 - /// `brand.ink/800`. - nonisolated static let darkKeyModifier: Color = Brand.ink800 - /// `brand.paper/tint`. - nonisolated static let darkLegend: Color = Brand.paperTint - /// `brand.neutral/500`. - nonisolated static let darkLegendSecondary: Color = Brand.neutral500 - /// `brand.material/dark-surface`. - nonisolated static let darkMaterial: Color = Brand.materialDarkSurface - /// `brand.orange`. - nonisolated static let darkNotice: Color = Brand.orange - /// `brand.ink/700`. - nonisolated static let darkPopup: Color = Brand.ink700 - /// `brand.green/400`. - nonisolated static let darkSignal: Color = Brand.green400 - /// `brand.ink`. - nonisolated static let darkSurface: Color = Brand.ink - /// `brand.white`. - nonisolated static let lightContainerKey: Color = Brand.white - /// `brand.paper/300`. - nonisolated static let lightContainerKeyModifier: Color = Brand.paper300 - /// `brand.white`. - nonisolated static let lightField: Color = Brand.white - /// `brand.paper/400`. - nonisolated static let lightFieldBorder: Color = Brand.paper400 - /// `brand.white`. - nonisolated static let lightKey: Color = Brand.white - /// `brand.paper/300`. - nonisolated static let lightKeyModifier: Color = Brand.paper300 - /// `brand.ink`. - nonisolated static let lightLegend: Color = Brand.ink - /// `brand.neutral/700`. - nonisolated static let lightLegendSecondary: Color = Brand.neutral700 - /// `brand.material/light-surface`. - nonisolated static let lightMaterial: Color = Brand.materialLightSurface - /// `brand.orange`. - nonisolated static let lightNotice: Color = Brand.orange - /// `brand.white`. - nonisolated static let lightPopup: Color = Brand.white - /// `brand.green/700`. - nonisolated static let lightSignal: Color = Brand.green700 - /// `brand.paper/200`. - nonisolated static let lightSurface: Color = Brand.paper200 - } - - 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 - /// The catalog's CardBorder in dark. - nonisolated static let appCardBorderDark: Color = Brand.ink600 - /// The catalog's CardBorder in light. - nonisolated static let appCardBorderLight: Color = Brand.paper400 - /// The catalog's CardFill in dark. - 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 - /// The catalog's CTAText in dark: ink on green, as the site. - nonisolated static let appCtaTextDark: Color = Brand.ink - /// The catalog's CTAText in light. - nonisolated static let appCtaTextLight: Color = Brand.white - /// The catalog's Muted in dark. - nonisolated static let appMutedDark: Color = Brand.neutral500 - /// The catalog's Muted in light: eyebrows, captions, secondary text. - 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 - /// The catalog's Page in light: the design system's page. - nonisolated static let appPageLight: Color = Brand.paperPage - /// The catalog's Text in dark. - nonisolated static let appTextDark: Color = Brand.paperTint - /// The catalog's Text in light. - nonisolated static let appTextLight: Color = Brand.ink - /// The panel's cancel ×. - nonisolated static let kbCancel: Color = Brand.orange - /// The key-term field. - nonisolated static let kbField: Color = Themes.darkField - /// The field's hairline. - nonisolated static let kbFieldBorder: Color = Themes.darkFieldBorder - /// The one line of words the keyboard ever shows. - nonisolated static let kbFullAccessNote: Color = Brand.orange - /// An ordinary key. - nonisolated static let kbKey: Color = Themes.darkKey - /// Shift, delete, globe, return, 123, cancel. - nonisolated static let kbKeyModifier: Color = Themes.darkKeyModifier - /// Key legends and bare glyphs. - 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 - /// The solid ring and glyph for an error. - nonisolated static let kbNoticeError: Color = Brand.orange - /// The letter pop-up. - nonisolated static let kbPopup: Color = Themes.darkPopup - /// The wave, the caret, the saved check. - nonisolated static let kbSignal: Color = Themes.darkSignal - /// The design face's surface (Swift reads the palette). - nonisolated static let kbSurface: Color = Themes.darkSurface - } - - enum Metrics { - /// Between the chip's glyph and the word. - 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 - /// The selected-word chip's side padding. - 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 - /// The brand's button. - nonisolated static let appButtonHeight: CGFloat = 40 - /// Its side padding. - nonisolated static let appButtonPad: CGFloat = 12 - /// Inside a card. - nonisolated static let appCardPad: CGFloat = 16 - /// Between chips. - nonisolated static let appChipGap: CGFloat = 8 - /// A style chip's side padding. - nonisolated static let appChipPadX: CGFloat = 12 - /// And its top and bottom. - nonisolated static let appChipPadY: CGFloat = 8 - /// The home header: wordmark and gear. - nonisolated static let appHeaderHeight: CGFloat = 44 - /// Between the hero's pieces. - nonisolated static let appHeroGap: CGFloat = 14 - /// Inside the hero card. - nonisolated static let appHeroPad: CGFloat = 20 - /// The header's gear, the copy glyph. - nonisolated static let appIcon: CGFloat = 20 - /// Between lines of one thought. - nonisolated static let appLineGap: CGFloat = 6 - /// The screens' side padding. - nonisolated static let appPagePad: CGFloat = 20 - /// Between the theme card's two faces. - nonisolated static let appPreviewGap: CGFloat = 8 - /// Between the home screen's sections. - nonisolated static let appSectionGap: CGFloat = 24 - /// The setup steps' mono numerals. - nonisolated static let appSetupNumberWidth: CGFloat = 24 - /// Between a section's eyebrow and its content, and between cards. - nonisolated static let appStackGap: CGFloat = 12 - /// The card's hairline. - nonisolated static let cardBorder: CGFloat = 1 - /// The app's cards (radius/card). - nonisolated static let cardRadius: CGFloat = 12 - 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 - /// 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 - /// The grille in the voice bar. - nonisolated static let grilleBarWidth: CGFloat = 64 - /// A dot's diameter. - 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 - /// The glint's arms. - nonisolated static let grilleGlintWidth: CGFloat = 1 - nonisolated static let grilleHomeHeight: CGFloat = 108 - /// The grille on the home screen. - nonisolated static let grilleHomeWidth: CGFloat = 240 - nonisolated static let grillePanelHeight: CGFloat = 72 - /// The grille in the panel. - nonisolated static let grillePanelWidth: CGFloat = 160 - /// The dot lattice: one dot every. - nonisolated static let grillePitch: CGFloat = 4 - /// The sheen's band, as a fraction of the width. - nonisolated static let grilleSheenWidth: CGFloat = 0.35 - /// 123 and the globe at 402 pt: the iPhone's. - nonisolated static let keyAbcWidth402: CGFloat = 43.333 - /// Between keys, the iPhone's. - nonisolated static let keyGap: CGFloat = 6 - /// Every key: the iPhone's cap height (apple-geometry.json). - 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 - /// A key that isn't given a width: the iPhone's 123 key at 402 pt. - nonisolated static let keyMinWidth: CGFloat = 43.333 - /// A legend's side padding on a min-width key. - 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 - /// The width the @402 tokens were measured at (iPhone 18 Pro); widths scale from it. - nonisolated static let keyReferenceWidth: CGFloat = 402 - /// Return at 402 pt: two 123 keys and a gap. - 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 - /// 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 - /// Space at 402 pt beside 123, the globe and return: what is left (measured 191.333). - 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 - /// Chosen: room for the 96 orb over one key row. - nonisolated static let layoutPanel: CGFloat = 216 - /// 2 × margin/vertical + voicebar/height. - 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 - /// At the keyboard's sides: the iPhone's cap margin, (402 − 10 letters − 9 gaps) / 2. - nonisolated static let marginSide: CGFloat = 6.5 - /// The keyboard's top, and the slim bar's bottom. - nonisolated static let marginVertical: CGFloat = 8 - /// The + without Full Access. - nonisolated static let opacityDisabled: CGFloat = 0.4 - /// The cross glint when the words land. - 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 - /// Film grain over the light face: paper, lighter. - 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 - /// Key legends over the cap; below 1 they sit back. - nonisolated static let opacityLegend: CGFloat = 1 - /// The + at rest. - 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 - /// The field's placeholder. - nonisolated static let opacityPlaceholder: CGFloat = 0.4 - nonisolated static let opacityPopupShadow: CGFloat = 0.12 - /// A key lightens this much while pressed. - nonisolated static let opacityPressBrighten: CGFloat = 0.15 - /// A brand button while pressed: a colour change, no lift. - 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 - /// A soft darkening toward the surface's edges; 0 is none. - nonisolated static let opacitySurfaceVignette: CGFloat = 0 - /// The field's ×. - 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 - /// The theme card draws the keyboard at this width. - nonisolated static let pickerPreviewWidth: CGFloat = 393 - /// The theme card's preview corners. - nonisolated static let pickerRadius: CGFloat = 10 - /// Then scales it to fit two across. - 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 - /// 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 - /// The one thing that floats. - nonisolated static let popupShadowRadius: CGFloat = 8 - nonisolated static let popupShadowY: CGFloat = 2 - /// The brand's buttons and the grille: rectangular, never a pill. - nonisolated static let radiusButton: CGFloat = 4 - /// The brand's cards. - nonisolated static let radiusCard: CGFloat = 12 - /// The brand's featured cards. - 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 - /// The ribs asleep: the wave's own bars at rest. - nonisolated static let ribsRestWidth: CGFloat = 40 - /// Between rows, the iPhone's. - 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 - /// The light streak while recording. - nonisolated static let streakHeight: CGFloat = 2 - /// The hairline at rest. - nonisolated static let streakLine: CGFloat = 1 - /// The bright point at the centre. - 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 - /// Where it is full. - 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 - /// The field's hairline. - nonisolated static let termBorder: CGFloat = 1 - /// × · field · ✓. - nonisolated static let termGap: CGFloat = 8 - /// The key-term field's capsule. - nonisolated static let termHeight: CGFloat = 36 - /// The field row's side inset. - nonisolated static let termInset: CGFloat = 2 - /// The field's side padding. - 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 - /// 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 - /// The box on the home screen. - nonisolated static let voiceHomeWidth: CGFloat = 280 - nonisolated static let voicePanelHeight: CGFloat = 88 - /// The box in the panel. - nonisolated static let voicePanelWidth: CGFloat = 300 - /// The element while pressed. - 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 - /// The voice row, where the system puts its suggestion bar. - nonisolated static let voicebarHeight: CGFloat = 44 - /// Between the orb and the Full Access note. - nonisolated static let voicebarNoteGap: CGFloat = 10 - /// A wave bar's width. - nonisolated static let waveBar: CGFloat = 2 - 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 - } - - enum Typography { - /// The clipboard and exclamation glyphs, as a fraction of the voice element's height. - nonisolated static let ratioVoiceGlyph: CGFloat = 0.34 - /// The app's body text. - nonisolated static let sizeBody: CGFloat = 16 - /// The app's small text. - nonisolated static let sizeCaption: CGFloat = 14 - /// A mono button label in the app (E2). - nonisolated static let sizeCta: CGFloat = 14 - /// A mono eyebrow in the app (E1). - nonisolated static let sizeEyebrow: CGFloat = 12 - /// The +, × and ✓. - nonisolated static let sizeGlyph: CGFloat = 17 - /// The mono word labels on keys: 123, ABC, #+=, space, the return label. - nonisolated static let sizeLabel: CGFloat = 12 - /// Every other key. - 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 - /// The letter pop-up. - nonisolated static let sizePopup: CGFloat = 32 - /// The key-term field. - nonisolated static let sizeTerm: CGFloat = 17 - /// The app's serif headline. - nonisolated static let sizeTitle: CGFloat = 34 - /// Uppercase mono at 14: the brand's E2 tracking. - 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 - } - - enum Motion { - /// The caret's blink. - 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 - /// A colour or fill changing state: the brand's transition. - nonisolated static let colour: Double = 0.2 - /// The house curve for everything that moves; nothing springs. - 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 - /// A facet's flash while recording. - nonisolated static let glintLife: Double = 0.5 - /// The keyboard resizing. - nonisolated static let heightChange: Double = 0.25 - /// A key lighting. - nonisolated static let keyPress: Double = 0.08 - /// The glint when the words land. - 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 - /// One turn of the ring, the Mac's cadence. - nonisolated static let ringPeriod: Double = 1.6 - /// One pass of the light over the element at rest. - nonisolated static let sheen: Double = 4 - /// And while something is happening: the ring's cadence. - 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 - /// The voice bar becoming the field. - nonisolated static let termSwap: Double = 0.4 - /// The orb and the wave crossing, either way. - nonisolated static let waveFade: Double = 0.7 - } - - 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] = [ - "brand.apple/dark-key": "#6B6B6B", - "brand.apple/dark-modifier": "#464646", - "brand.apple/dark-surface": "#2B2B2B", - "brand.apple/light-modifier": "#ADB3BC", - "brand.apple/light-surface": "#D1D5DB", - "brand.black": "#000000", - "brand.card": "#EBE8E8", - "brand.card-border": "#DEDBDB", - "brand.cobolt": "#3923C7", - "brand.green/100": "#CCE4D5", - "brand.green/200": "#99C8AC", - "brand.green/400": "#67AD82", - "brand.green/700": "#01762F", - "brand.green/mist": "#DBF5E6", - "brand.ink": "#1D1B16", - "brand.ink/600": "#3A362F", - "brand.ink/700": "#33302A", - "brand.ink/800": "#26231E", - "brand.material/dark-key": "#575757", - "brand.material/dark-modifier": "#474747", - "brand.material/dark-surface": "#373737", - "brand.material/light-surface": "#E2E3E8", - "brand.neutral/300": "#D2D1D0", - "brand.neutral/500": "#A5A4A2", - "brand.neutral/700": "#777673", - "brand.neutral/900": "#4A4945", - "brand.orange": "#E67F36", - "brand.paper/200": "#ECEBE5", - "brand.paper/300": "#DAD7CB", - "brand.paper/400": "#C7C3B2", - "brand.paper/page": "#FDFCF8", - "brand.paper/tint": "#F5F3EB", - "brand.violet/iris": "#887BDD", - "brand.violet/lavender": "#D7D3F4", - "brand.violet/periwinkle": "#B0A7E9", - "brand.warm-white": "#F2EEE6", - "brand.white": "#FFFFFF", - "themes.dark/container-key": "#575757", - "themes.dark/container-key-modifier": "#474747", - "themes.dark/field": "#33302A", - "themes.dark/field-border": "#3A362F", - "themes.dark/key": "#33302A", - "themes.dark/key-modifier": "#26231E", - "themes.dark/legend": "#F5F3EB", - "themes.dark/legend-secondary": "#A5A4A2", - "themes.dark/material": "#373737", - "themes.dark/notice": "#E67F36", - "themes.dark/popup": "#33302A", - "themes.dark/signal": "#67AD82", - "themes.dark/surface": "#1D1B16", - "themes.light/container-key": "#FFFFFF", - "themes.light/container-key-modifier": "#DAD7CB", - "themes.light/field": "#FFFFFF", - "themes.light/field-border": "#C7C3B2", - "themes.light/key": "#FFFFFF", - "themes.light/key-modifier": "#DAD7CB", - "themes.light/legend": "#1D1B16", - "themes.light/legend-secondary": "#777673", - "themes.light/material": "#E2E3E8", - "themes.light/notice": "#E67F36", - "themes.light/popup": "#FFFFFF", - "themes.light/signal": "#01762F", - "themes.light/surface": "#ECEBE5", - "keyboard.app/accent-dark": "#67AD82", - "keyboard.app/accent-light": "#01762F", - "keyboard.app/card-border-dark": "#3A362F", - "keyboard.app/card-border-light": "#C7C3B2", - "keyboard.app/card-fill-dark": "#26231E", - "keyboard.app/card-fill-light": "#F5F3EB", - "keyboard.app/cta-dark": "#67AD82", - "keyboard.app/cta-light": "#01762F", - "keyboard.app/cta-text-dark": "#1D1B16", - "keyboard.app/cta-text-light": "#FFFFFF", - "keyboard.app/muted-dark": "#A5A4A2", - "keyboard.app/muted-light": "#777673", - "keyboard.app/page-dark": "#1D1B16", - "keyboard.app/page-light": "#FDFCF8", - "keyboard.app/text-dark": "#F5F3EB", - "keyboard.app/text-light": "#1D1B16", - "keyboard.kb/cancel": "#E67F36", - "keyboard.kb/field": "#33302A", - "keyboard.kb/field-border": "#3A362F", - "keyboard.kb/full-access-note": "#E67F36", - "keyboard.kb/key": "#33302A", - "keyboard.kb/key-modifier": "#26231E", - "keyboard.kb/legend": "#F5F3EB", - "keyboard.kb/legend-secondary": "#A5A4A2", - "keyboard.kb/notice-error": "#E67F36", - "keyboard.kb/popup": "#33302A", - "keyboard.kb/signal": "#67AD82", - "keyboard.kb/surface": "#1D1B16", - "metrics.addterm/chip-gap": "6", - "metrics.addterm/chip-min-width": "96", - "metrics.addterm/chip-pad": "10", - "metrics.addterm/inset": "6", - "metrics.app/button-height": "40", - "metrics.app/button-pad": "12", - "metrics.app/card-pad": "16", - "metrics.app/chip-gap": "8", - "metrics.app/chip-pad-x": "12", - "metrics.app/chip-pad-y": "8", - "metrics.app/header-height": "44", - "metrics.app/hero-gap": "14", - "metrics.app/hero-pad": "20", - "metrics.app/icon": "20", - "metrics.app/line-gap": "6", - "metrics.app/page-pad": "20", - "metrics.app/preview-gap": "8", - "metrics.app/section-gap": "24", - "metrics.app/setup-number-width": "24", - "metrics.app/stack-gap": "12", - "metrics.card/border": "1", - "metrics.card/radius": "12", - "metrics.caret/height": "20", - "metrics.caret/lead": "1", - "metrics.caret/radius": "1", - "metrics.caret/width": "2", - "metrics.glyph/hit": "44", - "metrics.grille/bar-height": "28", - "metrics.grille/bar-width": "64", - "metrics.grille/dot": "2", - "metrics.grille/glint-arm": "1.6", - "metrics.grille/glint-width": "1", - "metrics.grille/home-height": "108", - "metrics.grille/home-width": "240", - "metrics.grille/panel-height": "72", - "metrics.grille/panel-width": "160", - "metrics.grille/pitch": "4", - "metrics.grille/sheen-width": "0.35", - "metrics.key/abc-width-402": "43.333", - "metrics.key/gap": "6", - "metrics.key/height": "43", - "metrics.key/letter-width-402": "33.5", - "metrics.key/min-width": "43.333", - "metrics.key/pad": "4", - "metrics.key/radius": "8", - "metrics.key/reference-width": "402", - "metrics.key/return-width-402": "92.667", - "metrics.key/side-gap": "13.667", - "metrics.key/side-width-402": "45.583", - "metrics.key/space-width-402": "191.667", - "metrics.layout/full": "270", - "metrics.layout/panel": "216", - "metrics.layout/slim": "60", - "metrics.margin/bottom-keys": "13", - "metrics.margin/side": "6.5", - "metrics.margin/vertical": "8", - "metrics.opacity/disabled": "0.4", - "metrics.opacity/glint": "0.95", - "metrics.opacity/grain-dark": "0.14", - "metrics.opacity/grain-light": "0.08", - "metrics.opacity/grille-rest": "0.55", - "metrics.opacity/hairline": "0.5", - "metrics.opacity/legend": "1", - "metrics.opacity/legend-muted": "0.5", - "metrics.opacity/off": "0.6", - "metrics.opacity/placeholder": "0.4", - "metrics.opacity/popup-shadow": "0.12", - "metrics.opacity/press-brighten": "0.15", - "metrics.opacity/pressed": "0.85", - "metrics.opacity/sheen": "0.22", - "metrics.opacity/surface-vignette": "0", - "metrics.opacity/term-cancel": "0.7", - "metrics.panel/spacing": "8", - "metrics.picker/preview-width": "393", - "metrics.picker/radius": "10", - "metrics.picker/scale": "0.42", - "metrics.popup/extra-width": "18", - "metrics.popup/height": "56", - "metrics.popup/offset": "50", - "metrics.popup/radius": "10", - "metrics.popup/shadow-radius": "8", - "metrics.popup/shadow-y": "2", - "metrics.radius/button": "4", - "metrics.radius/card": "12", - "metrics.radius/hero": "16", - "metrics.radius/input": "8", - "metrics.ribs/rest-height": "16", - "metrics.ribs/rest-width": "40", - "metrics.row/gap": "11", - "metrics.streak/arm": "1.4", - "metrics.streak/height": "2", - "metrics.streak/line": "1", - "metrics.streak/point": "4", - "metrics.surface/paint": "0", - "metrics.surface/vignette-end": "0.75", - "metrics.surface/vignette-start": "0.3", - "metrics.term/border": "1", - "metrics.term/gap": "8", - "metrics.term/height": "36", - "metrics.term/inset": "2", - "metrics.term/pad": "14", - "metrics.term/radius": "8", - "metrics.voice/bar-height": "32", - "metrics.voice/bar-width": "160", - "metrics.voice/home-height": "112", - "metrics.voice/home-width": "280", - "metrics.voice/panel-height": "88", - "metrics.voice/panel-width": "300", - "metrics.voice/press-scale": "0.94", - "metrics.voicebar/addterm-clearance": "6", - "metrics.voicebar/height": "44", - "metrics.voicebar/note-gap": "10", - "metrics.wave/bar": "2", - "metrics.wave/bar-height": "24", - "metrics.wave/gap": "2", - "metrics.wave/home-height": "44", - "metrics.wave/panel-height": "40", - "metrics.wordmark/height": "22", - "type.ratio/voice-glyph": "0.34", - "type.size/body": "16", - "type.size/caption": "14", - "type.size/cta": "14", - "type.size/eyebrow": "12", - "type.size/glyph": "17", - "type.size/label": "12", - "type.size/legend": "16", - "type.size/letter": "24", - "type.size/popup": "32", - "type.size/term": "17", - "type.size/title": "34", - "type.tracking/cta": "1.4", - "type.tracking/eyebrow": "1.2", - "type.weight/glyph": "medium", - "type.weight/label": "medium", - "type.weight/legend": "medium", - "type.weight/letter": "regular", - "type.weight/popup": "regular", - "type.weight/term": "regular", - "type.weight/voice-glyph": "semibold", - "motion.caret": "0.5", - "motion.chip-hold": "0.6", - "motion.colour": "0.2", - "motion.ease/signature": "0.22,1,0.36,1", - "motion.flip": "0.25", - "motion.glint-life": "0.5", - "motion.height-change": "0.25", - "motion.key-press": "0.08", - "motion.landing": "0.6", - "motion.press": "0.1", - "motion.ring-period": "1.6", - "motion.sheen": "4", - "motion.sheen-working": "1.6", - "motion.state-fade": "0.5", - "motion.term-swap": "0.4", - "motion.wave-fade": "0.7", - ] -} -// swiftlint:enable type_body_length diff --git a/App/BlurtiOS/project.yml b/App/BlurtiOS/project.yml index 4df8a7d0..699907b9 100644 --- a/App/BlurtiOS/project.yml +++ b/App/BlurtiOS/project.yml @@ -75,13 +75,32 @@ 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: + TARGETED_DEVICE_FAMILY: "1" + 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. @@ -92,10 +111,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 @@ -176,6 +191,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 @@ -185,7 +202,6 @@ targets: platform: iOS sources: - path: BlurtKeyboard/Sources - - path: Shared - path: BlurtKeyboard/PrivacyInfo.xcprivacy buildPhase: resources settings: @@ -218,10 +234,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/README.md b/README.md index e8612d1c..67162fda 100644 --- a/README.md +++ b/README.md @@ -224,8 +224,7 @@ Sources/BlurtEngine/ Swift 6 package owning the pipeline — no external dep STT/ AssemblyAITranscriber: one POST to dictation.assemblyai.com/v1/transcribe/live (STT + LLM rewrite); STTPrompt (contextual priming: your recent dictations then the text before the cursor, and - nothing else about your screen); KeytermsBoost (key terms as - the keyterms-prompt list) + nothing else about your screen) Pipeline/ DictationSession actor: press/release/cancel commands, phase stream, auto-release before the API's recording cap Hotkey/ DictationKeyGate/Router: pure, unit-tested state machine for the diff --git a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift index d4875504..9f26ec8a 100644 --- a/Sources/AssemblyAI/Dictation/DictationWireTypes.swift +++ b/Sources/AssemblyAI/Dictation/DictationWireTypes.swift @@ -46,7 +46,7 @@ public struct DictationConfig: Encodable, Sendable { /// 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 + /// `KeyTerms` 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 `[]`. /// diff --git a/Sources/BlurtEngine/STT/KeytermsBoost.swift b/Sources/AssemblyAI/Dictation/KeyTerms.swift similarity index 52% rename from Sources/BlurtEngine/STT/KeytermsBoost.swift rename to Sources/AssemblyAI/Dictation/KeyTerms.swift index 1fa49481..c7eef65b 100644 --- a/Sources/BlurtEngine/STT/KeytermsBoost.swift +++ b/Sources/AssemblyAI/Dictation/KeyTerms.swift @@ -1,51 +1,61 @@ -/// The user's key terms as the dictation request's `config.keyterms_prompt` — -/// keyterms prompting, the other half of transcription steering next to -/// `STTPrompt`. +/// Key terms — names, product names, jargon — for the dictation request's +/// `config.keyterms_prompt`: keyterms prompting, the other half of +/// transcription steering next to the `stt_prompt` context. /// /// The two fields do different jobs and ride the same request: -/// `stt_prompt` is the prior text (the user's recent dictations, -/// then the text before the cursor) that tells the model what this utterance -/// continues; `keyterms_prompt` is a flat list of strings biasing recognition -/// toward those exact spellings. Boosting is the right tool for an explicit vocabulary -/// list — names, product names, jargon — which is exactly what the Settings -/// "Key Terms" field collects, and the wrong thing to pack into prose context (a -/// `Keywords: a, b, c.` clause is what this replaces). +/// `stt_prompt` is prior text (recent dictations, then the text before the +/// cursor) that tells the model what this utterance continues; +/// `keyterms_prompt` is a flat list of strings biasing recognition toward those +/// exact spellings. Boosting is the right tool for an explicit vocabulary list, +/// and the wrong thing to pack into prose context (a `Keywords: a, b, c.` +/// clause). /// /// **`keyterms_prompt`, not `word_boost`.** Three names are the same feature — /// `keyterms_prompt` is the one the Streaming and Pre-recorded surfaces use and -/// the one the dictation reference now documents, with `keyterms` and -/// `word_boost` kept as legacy aliases. This sent `word_boost` until 2026-09-10, -/// on the belief that it was the documented name and `keyterms_prompt` merely a -/// pass-through among the unknown fields the endpoint forwards as-is. Both halves -/// of that were wrong, measured against `/v1/transcribe/live`: the route +/// the one the dictation reference documents, with `keyterms` and `word_boost` +/// kept as legacy aliases. Measured against `/v1/transcribe/live`: the route /// validates its config strictly and rejects an unknown key outright -/// (`Extra inputs are not permitted`), so `keyterms_prompt` returning 200 is -/// proof it is a parameter the route knows — and the service's own error names it -/// first: `provide only one of keyterms_prompt, keyterms, or word_boost`. +/// (`Extra inputs are not permitted`), and the service's own error names it +/// first: `provide only one of keyterms_prompt, keyterms, or word_boost`. The +/// aliases are mutually exclusive on this route, so a request carrying two names +/// 400s before the audio is read. Send exactly one. /// -/// Send exactly one. The aliases are mutually exclusive on this route, not just -/// on the sibling one, so a belt-and-braces request carrying two names 400s -/// before the audio is read — which makes the migration a swap and never an -/// addition. -/// -/// The terms arrive already normalized — `KeyTermsStore.parse` splits on commas, -/// trims, drops blanks, and dedupes case-insensitively — so the only thing left -/// to enforce here is size: the field accepts at most `characterCap` across -/// all terms and at most `termCap` terms, and the user's list is the one -/// unbounded input in the request. -/// Exercised by `Tests/BlurtEngineTests/KeytermsBoostTests.swift`. -enum KeytermsBoost { +/// Two steps, both here: `parse` turns what a person types (a comma-separated +/// list) into clean terms, and `fitted` keeps a list within the field's two +/// caps, which the user's list is the one unbounded input able to breach. +public enum KeyTerms { + /// A comma-separated list as clean terms: split on commas (only commas, so + /// "San Francisco" stays one term), trimmed, blanks dropped, and duplicates + /// removed case-insensitively, keeping the first spelling and the user's + /// order. `fitted` assumes terms arrive this way. + public 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 } + 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/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 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/check-invariants.sh b/scripts/check-invariants.sh index 549fcc4f..d597f837 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" 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"