diff --git a/App/BlurtiOS/BlurtDesign/DesignTokens.swift b/App/BlurtiOS/BlurtDesign/DesignTokens.swift index bb240fa7..faebbb9a 100644 --- a/App/BlurtiOS/BlurtDesign/DesignTokens.swift +++ b/App/BlurtiOS/BlurtDesign/DesignTokens.swift @@ -417,6 +417,18 @@ package enum DesignTokens { package nonisolated static let termPad: CGFloat = 14 /// The key-term field: the brand's input corners (radius/input). package nonisolated static let termRadius: CGFloat = 8 + /// The typing field's border, orange once a word goes wrong. + package nonisolated static let typingFieldBorder: CGFloat = 2 + /// Inside the typing field. + package nonisolated static let typingFieldPad: CGFloat = 12 + /// Between the lines of words to type. + package nonisolated static let typingLineSpacing: CGFloat = 8 + /// The countdown's thump in the last five seconds. + package nonisolated static let typingPulse: CGFloat = 1.06 + /// Between the result's blocks. + package nonisolated static let typingResultGap: CGFloat = 20 + /// The least room the words take, so the field below stays put. + package nonisolated static let typingWordsHeight: CGFloat = 180 package nonisolated static let voiceBarHeight: CGFloat = 32 /// The voice element's box in the voice bar — the most it takes (the ribs awake, the streak); the + sits beside what shows. package nonisolated static let voiceBarWidth: CGFloat = 160 @@ -485,6 +497,20 @@ package enum DesignTokens { package nonisolated static let sizeTerm: CGFloat = 17 /// The app's serif headline. package nonisolated static let sizeTitle: CGFloat = 34 + /// The typing test's countdown. + package nonisolated static let sizeTypingClock: CGFloat = 64 + /// What you've typed. + package nonisolated static let sizeTypingField: CGFloat = 22 + /// Its live words per minute. + package nonisolated static let sizeTypingLive: CGFloat = 28 + /// The result's rank, in the serif. + package nonisolated static let sizeTypingRank: CGFloat = 28 + /// The result's words per minute. + package nonisolated static let sizeTypingResult: CGFloat = 96 + /// The result's WPM. + package nonisolated static let sizeTypingUnit: CGFloat = 16 + /// The words to type. + package nonisolated static let sizeTypingWords: CGFloat = 24 /// Uppercase mono at 14: the brand's E2 tracking. package nonisolated static let trackingCta: CGFloat = 1.4 /// Uppercase mono at 12: the brand's E1 tracking. @@ -530,6 +556,10 @@ package enum DesignTokens { package nonisolated static let stateFade: Double = 0.5 /// The voice bar becoming the field. package nonisolated static let termSwap: Double = 0.4 + /// The typing field's border turning, and the countdown's thump. + package nonisolated static let typingFlash: Double = 0.15 + /// How often the typing test's clock and live speed redraw. + package nonisolated static let typingTick: Double = 0.1 /// The orb and the wave crossing, either way. package nonisolated static let waveFade: Double = 0.7 } @@ -745,6 +775,12 @@ package enum DesignTokens { "metrics.term/inset": "2", "metrics.term/pad": "14", "metrics.term/radius": "8", + "metrics.typing/field-border": "2", + "metrics.typing/field-pad": "12", + "metrics.typing/line-spacing": "8", + "metrics.typing/pulse": "1.06", + "metrics.typing/result-gap": "20", + "metrics.typing/words-height": "180", "metrics.voice/bar-height": "32", "metrics.voice/bar-width": "160", "metrics.voice/home-height": "112", @@ -781,6 +817,13 @@ package enum DesignTokens { "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", @@ -805,6 +848,8 @@ package enum DesignTokens { "motion.sheen-working": "1.6", "motion.state-fade": "0.5", "motion.term-swap": "0.4", + "motion.typing-flash": "0.15", + "motion.typing-tick": "0.1", "motion.wave-fade": "0.7", ] } diff --git a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift index 9eb84874..a1035458 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/BlurtiOSApp.swift @@ -45,6 +45,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) diff --git a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift index 5fb72257..78c9ff40 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/SettingsView.swift @@ -20,6 +20,8 @@ struct SettingsView: View { VoiceElementKind.shipped.rawValue @State private var showsKeyEntry = false @State private var confirmsStatsReset = false + @State private var showsTypingTest = false + @AppStorage(TypingSpeed.defaultsKey) private var typingSpeed: Double = 0 @AppStorage(EnhancedTranscriptsStore.defaultsKey) private var enhancedTranscripts = EnhancedTranscriptsStore.defaultValue @@ -40,6 +42,14 @@ 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() } + .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) } @@ -130,11 +140,6 @@ struct SettingsView: View { } footer: { Text("Dictations, words and time saved are counted on this phone and never sent.") } - .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.") - } } private var aboutSection: some View { @@ -143,6 +148,19 @@ struct SettingsView: View { Link("Blurt on GitHub", destination: URL(string: "https://github.com/AssemblyAI/blurt") ?? URL(filePath: "/")) Link("AssemblyAI's privacy policy", destination: AIConsent.privacyPolicyURL) Text("Powered by AssemblyAI").foregroundStyle(BlurtBrand.muted) + // The easter egg: a link, not a button, so it stays a find rather than + // another call to action. Its result becomes time saved's typing speed. + Button { + showsTypingTest = true + } label: { + Label("How fast can you type?", systemImage: "keyboard") + } + .foregroundStyle(BlurtBrand.accent) + if typingSpeed > 0 { + Button("Using your \(Int(typingSpeed)) wpm · Reset to average") { TypingSpeed.resetToAverage() } + .font(BlurtType.body(DesignTokens.Typography.sizeCaption)) + .foregroundStyle(BlurtBrand.muted) + } } header: { Eyebrow("About") } diff --git a/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift b/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift index c8bbe241..4c4bdd86 100644 --- a/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift +++ b/App/BlurtiOS/BlurtiOS/Sources/StatsCard.swift @@ -7,10 +7,13 @@ import SwiftUI /// 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() + 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)) } diff --git a/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift b/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift new file mode 100644 index 00000000..b9a98e29 --- /dev/null +++ b/App/BlurtiOS/BlurtiOS/Sources/TypingTestView.swift @@ -0,0 +1,261 @@ +import BlurtDesign +import BlurtiOSCore +import SwiftUI +import UIKit + +/// The easter egg: a 30-second typing sprint, full screen, from Settings. The +/// clock starts on the first keystroke, the last five seconds turn orange and +/// thump, and the result becomes the typing speed time saved is measured +/// against, in place of the 36 wpm phone average. +struct TypingTestView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private enum Phase: Equatable { + case ready + case running(since: Date) + case finished(wordsPerMinute: Double, accuracy: Double, result: TypingSpeed.Result) + + var startDate: Date? { + if case .running(let since) = self { return since } + return nil + } + } + + @State private var test = TypingTest() + @State private var phase = Phase.ready + @State private var input = "" + @State private var best = TypingSpeed.best() + @FocusState private var isTyping: Bool + + private static let seconds = Double(TypingTest.duration.components.seconds) + + var body: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { + HStack { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: DesignTokens.Metrics.appIcon, weight: DesignTokens.Typography.weightGlyph)) + .foregroundStyle(BlurtBrand.muted) + .frame(width: DesignTokens.Metrics.appHeaderHeight, height: DesignTokens.Metrics.appHeaderHeight) + } + .accessibilityLabel("Close") + Spacer() + Eyebrow("How fast can you type?") + Spacer() + Color.clear.frame(width: DesignTokens.Metrics.appHeaderHeight, height: DesignTokens.Metrics.appHeaderHeight) + } + + switch phase { + case .ready, .running: + sprint + case .finished(let wordsPerMinute, let accuracy, let result): + results(wordsPerMinute: wordsPerMinute, accuracy: accuracy, result: result) + } + } + .padding(.horizontal, DesignTokens.Metrics.appPagePad) + .page() + .onAppear { isTyping = true } + .task(id: phase.startDate) { await runClock() } + } + + // MARK: - The sprint + + private var sprint: some View { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appSectionGap) { + TimelineView(.periodic(from: .now, by: DesignTokens.Motion.typingTick)) { context in + let remaining = remainingSeconds(at: context.date) + let isFinalStretch = + phase.startDate != nil && remaining <= Double(TypingTest.finalStretch.components.seconds) + HStack(alignment: .firstTextBaseline) { + Text(String(format: "%.1f", remaining)) + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingClock, weight: .bold)) + .monospacedDigit() + .foregroundStyle(isFinalStretch ? BlurtBrand.errorOrange : BlurtBrand.text) + .scaleEffect( + isFinalStretch && !reduceMotion && pulse(at: context.date) ? DesignTokens.Metrics.typingPulse : 1, + anchor: .leading + ) + .animation(.easeOut(duration: DesignTokens.Motion.typingFlash), value: Int(remaining)) + Spacer() + VStack(alignment: .trailing, spacing: DesignTokens.Metrics.appLineGap) { + Eyebrow("WPM") + Text(liveWordsPerMinute(at: context.date)) + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingLive, weight: .bold)) + .monospacedDigit() + .foregroundStyle(BlurtBrand.accent) + } + } + .accessibilityElement(children: .combine) + } + + wordsText + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingWords, weight: .medium)) + .lineSpacing(DesignTokens.Metrics.typingLineSpacing) + .frame(maxWidth: .infinity, minHeight: DesignTokens.Metrics.typingWordsHeight, alignment: .topLeading) + .accessibilityLabel("Next word: \(test.currentWord)") + + TextField("Start typing to begin", text: $input) + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingField)) + .foregroundStyle(BlurtBrand.text) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.asciiCapable) + .focused($isTyping) + .padding(DesignTokens.Metrics.typingFieldPad) + .background(BlurtBrand.cardFill, in: RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput)) + .overlay( + RoundedRectangle(cornerRadius: DesignTokens.Metrics.radiusInput) + .strokeBorder( + input.isEmpty || test.isOnTrack(input) ? BlurtBrand.cardBorder : BlurtBrand.errorOrange, + lineWidth: DesignTokens.Metrics.typingFieldBorder) + ) + .animation(.easeInOut(duration: DesignTokens.Motion.typingFlash), value: test.isOnTrack(input)) + .onChange(of: input) { _, typed in handle(typed) } + + Text(phase == .ready ? "30 seconds. The clock starts on your first key." : "Space after each word.") + .brandFootnote() + Spacer(minLength: 0) + } + } + + /// A stable block of words: it moves on in chunks of eight, so the words + /// under your eyes don't jump while you type. + private var wordsText: Text { + let start = (test.index / 8) * 8 + var text = Text("") + for position in start..<(start + 20) { + var styled = Text(test.words[position % test.words.count]) + if position < test.index { + styled = + test.outcomes[position] + ? styled.foregroundStyle(BlurtBrand.muted) + : styled.foregroundStyle(BlurtBrand.errorOrange).strikethrough() + } else if position == test.index { + styled = styled.foregroundStyle(BlurtBrand.accent).bold().underline() + } else { + styled = styled.foregroundStyle(BlurtBrand.text) + } + text = text + styled + Text(" ") + } + return text + } + + private func handle(_ typed: String) { + if phase == .ready, !typed.isEmpty { phase = .running(since: .now) } + guard case .running = phase, let last = typed.last, last.isWhitespace else { return } + let word = typed.trimmingCharacters(in: .whitespaces) + input = "" + guard !word.isEmpty else { return } + if test.submit(word) { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } else { + UINotificationFeedbackGenerator().notificationOccurred(.error) + } + } + + /// Runs once per sprint: waits out the clock, thumps through the final + /// stretch, then scores. + private func runClock() async { + guard let start = phase.startDate else { return } + let stretch = Int(TypingTest.finalStretch.components.seconds) + let untilStretch = start.addingTimeInterval(Self.seconds - Double(stretch)).timeIntervalSinceNow + try? await Task.sleep(for: .seconds(max(0, untilStretch))) + for _ in 0.. Double { + guard let start = phase.startDate else { return Self.seconds } + return max(0, Self.seconds - date.timeIntervalSince(start)) + } + + private func liveWordsPerMinute(at date: Date) -> String { + guard let start = phase.startDate else { return "--" } + let elapsed = date.timeIntervalSince(start) + // The first couple of seconds swing wildly, so hold off. + guard elapsed >= 2 else { return "--" } + return "\(Int(test.wordsPerMinute(after: .seconds(min(elapsed, Self.seconds))).rounded()))" + } + + private func pulse(at date: Date) -> Bool { + date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 1) < DesignTokens.Motion.typingFlash + } + + // MARK: - The result + + private func results(wordsPerMinute: Double, accuracy: Double, result: TypingSpeed.Result) -> some View { + let rank = TypingTest.rank(for: wordsPerMinute) + let rounded = Int(wordsPerMinute.rounded()) + let speedup = TypingTest.speakingWordsPerMinute / max(wordsPerMinute, 1) + return VStack(alignment: .leading, spacing: DesignTokens.Metrics.typingResultGap) { + VStack(alignment: .leading, spacing: DesignTokens.Metrics.appChipGap) { + Eyebrow( + result.isBest ? "New personal best" : "Best \(Int(best.rounded())) wpm", + color: result.isBest ? BlurtBrand.accent : BlurtBrand.muted) + HStack(alignment: .firstTextBaseline, spacing: DesignTokens.Metrics.appChipGap) { + Text("\(rounded)") + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingResult, weight: .bold)) + .foregroundStyle(BlurtBrand.text) + Text("WPM") + .font(BlurtType.mono(DesignTokens.Typography.sizeTypingUnit, weight: .medium)) + .foregroundStyle(BlurtBrand.muted) + } + Text(rank.title) + .font(BlurtType.heading(DesignTokens.Typography.sizeTypingRank)) + .foregroundStyle(BlurtBrand.text) + Text(rank.line) + .font(BlurtType.body(DesignTokens.Typography.sizeBody)) + .foregroundStyle(BlurtBrand.text) + } + .accessibilityElement(children: .combine) + + Eyebrow("\(Int((accuracy * 100).rounded()))% accuracy") + + Text( + speedup >= 1.5 + ? "Talking runs about 150 words a minute. That's \(String(format: "%.1f", speedup))× you." + : "Talking runs about 150 words a minute. Honestly, you might not need us." + ) + .font(BlurtType.body(DesignTokens.Typography.sizeBody)) + .foregroundStyle(BlurtBrand.text) + .fixedSize(horizontal: false, vertical: true) + .padding(DesignTokens.Metrics.appCardPad) + .frame(maxWidth: .infinity, alignment: .leading) + .card() + + Eyebrow( + result.setsSpeed + ? "Time saved now uses your \(rounded) wpm" : "Too few words to measure. Time saved is unchanged", + color: result.setsSpeed ? BlurtBrand.accent : BlurtBrand.muted) + + Button("Try again", action: restart) + .buttonStyle(BrandButtonStyle()) + Spacer(minLength: 0) + } + } +} diff --git a/App/BlurtiOS/BlurtiOSCore/TypingTest.swift b/App/BlurtiOS/BlurtiOSCore/TypingTest.swift new file mode 100644 index 00000000..5bd9e3fc --- /dev/null +++ b/App/BlurtiOS/BlurtiOSCore/TypingTest.swift @@ -0,0 +1,134 @@ +import Foundation + +/// The easter egg behind Settings: a 30-second sprint over random common words +/// that measures how fast the user types. Pure state, so the scoring is +/// tested; `TypingTestView` owns the clock and the theatrics. +/// +/// Scored the standard way: a "word" is five characters, and only correctly +/// typed words count, each with its trailing space. The word being typed when +/// time runs out doesn't count. +package nonisolated struct TypingTest: Sendable { + package static let duration: Duration = .seconds(30) + /// Seconds left when the clock turns and starts to thump. + package static let finalStretch: Duration = .seconds(5) + /// Conversational speech runs about 150 words a minute, the comparison the + /// results end on. + package static let speakingWordsPerMinute: Double = 150 + + package let words: [String] + package private(set) var index = 0 + package private(set) var outcomes: [Bool] = [] + private var correctCharacters = 0 + + package init(words: [String] = TypingTest.randomWords()) { + self.words = words.isEmpty ? ["blurt"] : words + } + + package var currentWord: String { words[index % words.count] } + + /// Scores `typed` against the current word and moves on. Returns whether it + /// was right. + @discardableResult + package mutating func submit(_ typed: String) -> Bool { + let isCorrect = typed == currentWord + outcomes.append(isCorrect) + if isCorrect { correctCharacters += typed.count + 1 } + index += 1 + return isCorrect + } + + /// Whether what's typed so far could still become the current word: the + /// field turns orange mid-word when it can't. + package func isOnTrack(_ partial: String) -> Bool { currentWord.hasPrefix(partial) } + + package func wordsPerMinute(after elapsed: Duration) -> Double { + let minutes = Double(elapsed.components.seconds) / 60 + Double(elapsed.components.attoseconds) / 6e19 + guard minutes > 0 else { return 0 } + return Double(correctCharacters) / 5 / minutes + } + + package var accuracy: Double { + guard !outcomes.isEmpty else { return 0 } + return Double(outcomes.filter { $0 }.count) / Double(outcomes.count) + } + + package struct Rank: Equatable, Sendable { + package let title: String + package let line: String + } + + package static func rank(for wordsPerMinute: Double) -> Rank { + switch wordsPerMinute { + case ..<25: Rank(title: "Thumb wrestler", line: "Your thumbs are doing their best.") + case ..<40: Rank(title: "Steady thumbs", line: "Right around the phone average.") + case ..<55: Rank(title: "Speed thumbs", line: "Faster than most people on a phone.") + case ..<75: Rank(title: "Thumb demon", line: "Seriously quick.") + default: Rank(title: "Are you even human?", line: "We'd like to study your thumbs.") + } + } + + /// Plenty for a 30-second sprint even at 200 wpm. + package static func randomWords(count: Int = 120) -> [String] { + (0.. Double { + stored > 0 ? stored : DictationStats.averageTypingWordsPerMinute + } + + package static func current(in defaults: UserDefaults = .standard) -> Double { + resolved(defaults.double(forKey: defaultsKey)) + } + + package static func best(in defaults: UserDefaults = .standard) -> Double { + defaults.double(forKey: bestKey) + } + + /// Records a finished sprint: a new best is kept, and a meaningful result + /// becomes the time-saved speed, rounded as it's shown. + package static func record(_ wordsPerMinute: Double, in defaults: UserDefaults = .standard) -> Result { + let isBest = wordsPerMinute > best(in: defaults) + if isBest { defaults.set(wordsPerMinute, forKey: bestKey) } + let setsSpeed = wordsPerMinute >= minimumMeaningful + if setsSpeed { defaults.set(wordsPerMinute.rounded(), forKey: defaultsKey) } + return Result(isBest: isBest, setsSpeed: setsSpeed) + } + + package static func resetToAverage(in defaults: UserDefaults = .standard) { + defaults.removeObject(forKey: defaultsKey) + } + + package struct Result: Equatable, Sendable { + package let isBest: Bool + package let setsSpeed: Bool + } +} diff --git a/App/BlurtiOS/BlurtiOSTests/TypingTestTests.swift b/App/BlurtiOS/BlurtiOSTests/TypingTestTests.swift new file mode 100644 index 00000000..65a0e1ba --- /dev/null +++ b/App/BlurtiOS/BlurtiOSTests/TypingTestTests.swift @@ -0,0 +1,54 @@ +import Foundation +import Testing + +@testable import BlurtiOSCore + +@Suite("Easter egg typing sprint") +struct TypingTestTests { + private func scratchDefaults() throws -> UserDefaults { + let name = "TypingTestTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return defaults + } + + @Test("only right words score, five characters to a word with its space; accuracy counts every word") + func scoring() { + var test = TypingTest(words: ["hello", "there", "world"]) + #expect(test.isOnTrack("hel")) + #expect(!test.isOnTrack("hex")) + let first = test.submit("hello") + let second = test.submit("thare") + #expect(first) + #expect(!second) + #expect(test.currentWord == "world") + // "hello" plus its space is six characters: 1.2 words in half a minute. + #expect(test.wordsPerMinute(after: .seconds(30)) == 2.4) + #expect(test.accuracy == 0.5) + #expect(test.wordsPerMinute(after: .zero) == 0) + #expect(TypingTest(words: []).currentWord == "blurt") + } + + @Test("the ranks climb with speed") + func ranks() { + #expect(TypingTest.rank(for: 10).title == "Thumb wrestler") + #expect(TypingTest.rank(for: 36).title == "Steady thumbs") + #expect(TypingTest.rank(for: 200).title == "Are you even human?") + #expect(TypingTest.randomWords(count: 7).count == 7) + } + + @Test("a result sets time saved's speed and the best; too few words changes nothing; reset goes back to average") + func speed() throws { + let defaults = try scratchDefaults() + #expect(TypingSpeed.current(in: defaults) == DictationStats.averageTypingWordsPerMinute) + #expect(TypingSpeed.record(52.4, in: defaults) == .init(isBest: true, setsSpeed: true)) + #expect(TypingSpeed.current(in: defaults) == 52) + #expect(TypingSpeed.record(40, in: defaults) == .init(isBest: false, setsSpeed: true)) + #expect(TypingSpeed.current(in: defaults) == 40) + #expect(TypingSpeed.best(in: defaults) == 52.4) + #expect(TypingSpeed.record(2, in: defaults) == .init(isBest: false, setsSpeed: false)) + #expect(TypingSpeed.current(in: defaults) == 40) + TypingSpeed.resetToAverage(in: defaults) + #expect(TypingSpeed.current(in: defaults) == DictationStats.averageTypingWordsPerMinute) + } +} diff --git a/App/BlurtiOS/DESIGN.md b/App/BlurtiOS/DESIGN.md index 3673857a..b45c49b6 100644 --- a/App/BlurtiOS/DESIGN.md +++ b/App/BlurtiOS/DESIGN.md @@ -190,6 +190,13 @@ point sizes throughout, as the system keyboard's; no Dynamic Type. | `size/tab-label` | `11` | `Typography.sizeTabLabel` | a tab's mono label | | `size/term` | `17` | `Typography.sizeTerm` | the key-term field | | `size/title` | `34` | `Typography.sizeTitle` | the app's serif headline | +| `size/typing-clock` | `64` | `Typography.sizeTypingClock` | the typing test's countdown | +| `size/typing-field` | `22` | `Typography.sizeTypingField` | what you've typed | +| `size/typing-live` | `28` | `Typography.sizeTypingLive` | its live words per minute | +| `size/typing-rank` | `28` | `Typography.sizeTypingRank` | the result's rank, in the serif | +| `size/typing-result` | `96` | `Typography.sizeTypingResult` | the result's words per minute | +| `size/typing-unit` | `16` | `Typography.sizeTypingUnit` | the result's WPM | +| `size/typing-words` | `24` | `Typography.sizeTypingWords` | the words to type | | `tracking/cta` | `1.4` | `Typography.trackingCta` | uppercase mono at 14: the brand's E2 tracking | | `tracking/eyebrow` | `1.2` | `Typography.trackingEyebrow` | uppercase mono at 12: the brand's E1 tracking | | `weight/glyph` | `medium` | `Typography.weightGlyph` | — | @@ -638,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` | — | @@ -680,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 | @@ -732,6 +747,15 @@ About (with AssemblyAI's privacy policy). The theme picker shows each theme's two faces side by side. Styles, the import sheet, the key entry and the consent sheet share the form's chrome (`brandForm()`). +**The easter egg** (`TypingTestView`, `TypingTest`, `TypingSpeed`): a quiet +"How fast can you type?" link at the foot of Settings opens a 30-second sprint +over common words, full screen — a mono countdown that turns orange and thumps +through the last five seconds (no thump under Reduce Motion), live WPM in the +accent, the current word underlined, wrong words struck through in orange, +the field's border orange once a word goes wrong. The result: WPM, a rank, +accuracy, the best, and talking's 150 wpm against yours; the latest result +becomes time saved's typing speed ("Using your N wpm · Reset to average"). + **Consent** (`ConsentView`, `AIConsent`): before the listening window first opens, a sheet names AssemblyAI, lists what is sent (the voice while dictating, the text before the cursor, recent dictations, key terms with diff --git a/App/BlurtiOS/Design/tokens.json b/App/BlurtiOS/Design/tokens.json index 87cd602a..9e677fe8 100644 --- a/App/BlurtiOS/Design/tokens.json +++ b/App/BlurtiOS/Design/tokens.json @@ -792,6 +792,30 @@ "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": { @@ -903,6 +927,34 @@ "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": { @@ -969,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" } } }