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/skills/project-guardrails/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,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
30 changes: 16 additions & 14 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
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)
}
}
33 changes: 33 additions & 0 deletions App/BlurtiOS/BlurtDesign/Chrome.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,43 @@ package struct BrandButtonStyle: ButtonStyle {
role == .secondary ? BlurtBrand.cardBorder : Color.clear, lineWidth: DesignTokens.Metrics.cardBorder)
)
.opacity(configuration.isPressed ? DesignTokens.Metrics.opacityPressed : 1)
.modifier(FadedWhenDisabled())
.animation(.easeInOut(duration: DesignTokens.Motion.colour), value: configuration.isPressed)
}
}

/// A brand button that can't be pressed yet sits back rather than looking live.
/// A modifier because a `ButtonStyle` can't read the environment itself.
private struct FadedWhenDisabled: ViewModifier {
@Environment(\.isEnabled) private var isEnabled

func body(content: Content) -> some View {
content.opacity(isEnabled ? 1 : DesignTokens.Metrics.opacityDisabled)
}
}

extension View {
/// The brand's input: the page colour inside the card, 8 pt corners, the
/// card's hairline, the body face.
package func brandInput() -> some View {
font(BlurtType.body(DesignTokens.Typography.sizeBody))
.foregroundStyle(BlurtBrand.text)
.padding(.horizontal, DesignTokens.Metrics.appChipPadX)
.padding(.vertical, DesignTokens.Metrics.appInputPadY)
.background(BlurtBrand.page, in: RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput))
.overlay(
RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput)
.strokeBorder(BlurtBrand.cardBorder, lineWidth: DesignTokens.Metrics.cardBorder))
}

/// The brand's small print under a card: the caption size, muted.
package func brandFootnote() -> some View {
font(BlurtType.body(DesignTokens.Typography.sizeCaption))
.foregroundStyle(BlurtBrand.muted)
.fixedSize(horizontal: false, vertical: true)
}
}

/// The lowercase wordmark from the Mac's ready screen, tinted with the accent.
/// The image (`blurt-ready-logo.png`, shared from `Design/brand`) is the host
/// bundle's resource, not the library's; without it the word is set in the
Expand Down
35 changes: 34 additions & 1 deletion App/BlurtiOS/BlurtDesign/DesignTokens.swift
Original file line number Diff line number Diff line change
Expand Up @@ -229,18 +229,32 @@ package enum DesignTokens {
package nonisolated static let appHeroPad: CGFloat = 20
/// The header's gear, the copy glyph.
package nonisolated static let appIcon: CGFloat = 20
/// An app input's top and bottom (its sides are app/chip-pad-x).
package nonisolated static let appInputPadY: CGFloat = 10
/// Between lines of one thought.
package nonisolated static let appLineGap: CGFloat = 6
/// The screens' side padding.
package nonisolated static let appPagePad: CGFloat = 20
/// A key-term pill's top and bottom, and its side beside the ×.
package nonisolated static let appPillPadY: CGFloat = 4
/// The × on a key-term pill: its tap square.
package nonisolated static let appPillRemove: CGFloat = 24
/// Between the theme card's two faces.
package nonisolated static let appPreviewGap: CGFloat = 8
/// A list row's top and bottom inside a card.
package nonisolated static let appRowPadY: CGFloat = 8
/// The × on a text-shortcut row: its tap square.
package nonisolated static let appRowRemove: CGFloat = 32
/// Between the home screen's sections.
package nonisolated static let appSectionGap: CGFloat = 24
/// The setup steps' mono numerals.
package nonisolated static let appSetupNumberWidth: CGFloat = 24
/// Between a section's eyebrow and its content, and between cards.
package nonisolated static let appStackGap: CGFloat = 12
/// Between a tab's icon and its label.
package nonisolated static let appTabGap: CGFloat = 4
/// A tab in the brand tab bar: its icon over its mono label.
package nonisolated static let appTabHeight: CGFloat = 52
/// The card's hairline.
package nonisolated static let cardBorder: CGFloat = 1
/// The app's cards (radius/card).
Expand Down Expand Up @@ -307,7 +321,7 @@ package enum DesignTokens {
package nonisolated static let marginSide: CGFloat = 6.5
/// The keyboard's top, and the slim bar's bottom.
package nonisolated static let marginVertical: CGFloat = 8
/// The + without Full Access.
/// The + without Full Access; a brand button that can't be pressed yet.
package nonisolated static let opacityDisabled: CGFloat = 0.4
/// The cross glint when the words land.
package nonisolated static let opacityGlint: CGFloat = 0.95
Expand Down Expand Up @@ -439,8 +453,16 @@ package enum DesignTokens {
package nonisolated static let sizeLegend: CGFloat = 16
/// Letter keys: the iPhone's (estimated from the l glyph, apple-geometry.json).
package nonisolated static let sizeLetter: CGFloat = 24
/// The × on a key-term pill.
package nonisolated static let sizePillGlyph: CGFloat = 10
/// The letter pop-up.
package nonisolated static let sizePopup: CGFloat = 32
/// The × on a text-shortcut row.
package nonisolated static let sizeRemoveGlyph: CGFloat = 12
/// A tab's icon.
package nonisolated static let sizeTabIcon: CGFloat = 18
/// A tab's mono label.
package nonisolated static let sizeTabLabel: CGFloat = 11
/// The key-term field.
package nonisolated static let sizeTerm: CGFloat = 17
/// The app's serif headline.
Expand Down Expand Up @@ -605,12 +627,19 @@ package enum DesignTokens {
"metrics.app/hero-gap": "14",
"metrics.app/hero-pad": "20",
"metrics.app/icon": "20",
"metrics.app/input-pad-y": "10",
"metrics.app/line-gap": "6",
"metrics.app/page-pad": "20",
"metrics.app/pill-pad-y": "4",
"metrics.app/pill-remove": "24",
"metrics.app/preview-gap": "8",
"metrics.app/row-pad-y": "8",
"metrics.app/row-remove": "32",
"metrics.app/section-gap": "24",
"metrics.app/setup-number-width": "24",
"metrics.app/stack-gap": "12",
"metrics.app/tab-gap": "4",
"metrics.app/tab-height": "52",
"metrics.card/border": "1",
"metrics.card/radius": "12",
"metrics.caret/height": "20",
Expand Down Expand Up @@ -718,7 +747,11 @@ package enum DesignTokens {
"type.size/label": "12",
"type.size/legend": "16",
"type.size/letter": "24",
"type.size/pill-glyph": "10",
"type.size/popup": "32",
"type.size/remove-glyph": "12",
"type.size/tab-icon": "18",
"type.size/tab-label": "11",
"type.size/term": "17",
"type.size/title": "34",
"type.tracking/cta": "1.4",
Expand Down
52 changes: 52 additions & 0 deletions App/BlurtiOS/BlurtDesign/FlowLayout.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
3 changes: 1 addition & 2 deletions App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ struct BlurtiOSApp: App {
}

private var home: some View {
HomeView(coordinator: coordinator)
.onOpenURL { coordinator.handle($0) }
MainView(coordinator: coordinator)
.task { await start() }
}

Expand Down
2 changes: 1 addition & 1 deletion App/BlurtiOS/BlurtiOS/Sources/DictationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ final class DictationCoordinator {

/// The user's own key terms (from the App Group, where the keyboard adds
/// them on the spot), then the contact names the keyboard read off the
/// phone, deduplicated case-insensitively. `KeytermsBoost` fits the list
/// phone, deduplicated case-insensitively. `KeyTerms` fits the list
/// to the request's caps (100 terms, 2048 bytes), first entries first, which
/// is why the typed terms lead.
nonisolated static func keyTerms() -> [String] {
Expand Down
29 changes: 4 additions & 25 deletions App/BlurtiOS/BlurtiOS/Sources/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ import BlurtEngine
import BlurtiOSCore
import SwiftUI

/// The app's one screen: the Blurt landing page on a phone. The wordmark and
/// The Dictate tab: the Blurt landing page on a phone. The wordmark and
/// the gear across the top; the status as an eyebrow, a serif headline and a
/// line of body text over the voice element; the one green button; then the
/// styles and the recent dictations under their eyebrows. Setup sits on top
/// while anything is missing; everything adjustable lives behind the gear.
struct HomeView: View {
var coordinator: DictationCoordinator
/// Opens Settings, which `MainView` presents over either tab.
let showSettings: () -> Void
@Environment(\.scenePhase) private var scenePhase
@State private var microphoneGranted = AVAudioApplication.shared.recordPermission == .granted
@State private var keyboardSeen = SharedStore.keyboardEverSeen
@State private var showsKeyEntry = false
@State private var showsSettings = false
/// When the last dictation's words landed — the hero's glint.
@State private var landedAt: Date?
/// TEMPORARY: the mic concept from Settings, so the hero shows the one
Expand Down Expand Up @@ -52,21 +53,6 @@ struct HomeView: View {
.environment(\.voiceElementKind, VoiceElementKind(rawValue: voiceRaw) ?? .shipped)
.toolbar(.hidden, for: .navigationBar)
.sheet(isPresented: $showsKeyEntry) { KeyEntryView(apiKey: coordinator.apiKey) }
.sheet(isPresented: $showsSettings) { SettingsView(coordinator: coordinator) }
.sheet(isPresented: Binding(get: { coordinator.needsConsent }, set: { coordinator.needsConsent = $0 })) {
ConsentView { Task { await coordinator.grantConsent() } }
}
.sheet(item: Binding(get: { coordinator.pendingTermPack }, set: { coordinator.pendingTermPack = $0 })) {
ImportTermsView(pack: $0)
}
.alert(
"Couldn't read that list",
isPresented: Binding(get: { coordinator.termPackUnreadable }, set: { coordinator.termPackUnreadable = $0 })
) {
Button("OK") {}
} message: {
Text("It isn't a Blurt key-term list, or it's too big.")
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { refreshStatus() }
}
Expand All @@ -82,14 +68,7 @@ struct HomeView: View {
HStack {
Wordmark()
Spacer()
Button {
showsSettings = true
} label: {
Image(systemName: "gearshape")
.font(.system(size: DesignTokens.Metrics.appIcon, weight: DesignTokens.Typography.weightGlyph))
.foregroundStyle(BlurtBrand.muted)
}
.accessibilityLabel("Settings")
SettingsButton(action: showSettings)
}
.frame(height: DesignTokens.Metrics.appHeaderHeight)
}
Expand Down
Loading