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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/agents/cleanup-reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion .claude/agents/swift6-concurrency-reviewer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/project-guardrails/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` —
Expand Down Expand Up @@ -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.
Expand Down
38 changes: 21 additions & 17 deletions AGENTS.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion App/Blurt/Blurt/Wizard/Steps/KeyTermsStepView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 4 additions & 3 deletions App/Blurt/Blurt/Wizard/TextShortcutsSection.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AssemblyAI
import BlurtEngine
import SwiftUI

Expand Down Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions App/BlurtiOS/BlurtDesign/BlurtBrand.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
52 changes: 52 additions & 0 deletions App/BlurtiOS/BlurtDesign/BrandTabBar.swift
Original file line number Diff line number Diff line change
@@ -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<Tab: Hashable & Identifiable>: 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<Tab>, 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)
}
}
Original file line number Diff line number Diff line change
@@ -1,55 +1,36 @@
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(
BlurtBrand.cardBorder, lineWidth: DesignTokens.Metrics.cardBorder))
}

/// 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)
}
.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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading