From c8b5d456a0f745750c2029e113940da4387974d4 Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Thu, 3 Sep 2026 20:06:14 -0700 Subject: [PATCH 01/11] Add a Waveform + Draft voice visual Keeps Compact's live draft under a slim Chart Line ribbon and wraps the shape in Edge Glow, without a particle center or the concert waveform styles. Refs #120. --- CONTEXT.md | 15 ++-- Talkify/App/AppDelegate.swift | 2 +- Talkify/App/AppSettings.swift | 9 +- Talkify/CoreHUD/HUDMetrics.swift | 10 +-- .../HUD/DictationHUDController.swift | 11 +-- .../Dictation/HUD/DictationHUDShellView.swift | 84 +++++++++++++------ .../HUD/Visuals/HUDCompactChartLineView.swift | 83 ++++++++++++++++++ .../HUD/Visuals/HUDEdgeGlowView.swift | 2 +- .../HUD/Visuals/HUDVoiceVisualStyle.swift | 22 ++++- .../DropTranscription/HUD/DropHUDStyle.swift | 12 +-- .../Sections/AppearanceSettingsView.swift | 19 +++-- TalkifyTests/AppSettingsTests.swift | 2 + TalkifyTests/HUDMetricsTests.swift | 10 ++- TalkifyTests/HUDNotchGeometryTests.swift | 28 +++++++ TalkifyTests/HUDPlaceholderTests.swift | 20 +++-- TalkifyTests/SettingsWindowTests.swift | 11 ++- docs/design/FEATURE.md | 13 +-- docs/design/README.md | 2 +- docs/design/hud-anatomy.md | 20 +++-- docs/design/voice-visuals.md | 20 +++-- docs/settings-ui-spec.md | 5 +- 21 files changed, 297 insertions(+), 103 deletions(-) create mode 100644 Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift diff --git a/CONTEXT.md b/CONTEXT.md index 38aeffe..089cf17 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -128,7 +128,7 @@ _Avoid_: Transcript history, cloud analytics - **HUD size** is a Settings slider from 20% to 100% in 5% steps, defaulting to 100%, and applies on every display rather than only where there is no notch - A display raises its own **HUD size** floor when the picked size would leave the shape narrower than the housing it descends from plus its fillets: a shape narrower than its housing reads as a tab floating under the notch, and its fillets land inside the cutout with no bezel to meet - That floor cannot be one number: a notch is a fixed physical width but its width in points moves with the scaled display mode, so the same MacBook reports roughly 155 points under More Space and 273 under Larger Text; a display with no notch keeps the slider's own minimum, which is where a smaller HUD is wanted because every point the shape covers is screen the user was working in -- A HUD that will show draft text has a second floor at 40%, where the draft is still 6 points: Compact is built around the live draft and Reduce Motion restores it for every visual, so both stop there while Waveform and Edge Glow, which replace the draft entirely, go down to 20% +- A HUD that will show draft text has a second floor at 40%, where the draft is still 6 points: Compact and Waveform + Draft are built around the live draft and Reduce Motion restores it for every visual, so those stop there while Waveform and Edge Glow, which replace the draft entirely, go down to 20% - The **HUD size** slider offers only the sizes the selected voice visual can be shown at, and the higher of the two floors wins whenever both apply - A size below the selected visual's floor stays persisted and returns when a visual that can show it is picked again - The housing band and the fillets never scale with **HUD size**: the band's height is the physical notch on a notched display and the menu bar's clearance elsewhere, and a fillet exists to meet a physical bezel @@ -142,7 +142,7 @@ _Avoid_: Transcript history, cloud analytics - HUD status and error messages dismiss themselves after about two seconds - The HUD writes nothing while it retracts and its bands do not resize: the shape leaves exactly as it stood, and its text and layout reset only once it is off screen - The wait between the last word and the recognized text is silent — no label stands in for it, and the voice visual comes to rest rather than reading the silence as a dead microphone -- Compact opens with no text at all, because it is built around the live draft and a placeholder would be words nobody spoke +- Compact and Waveform + Draft open with no text at all, because they are built around the live draft and a placeholder would be words nobody spoke - The HUD's shaders and the particle cloud's compute kernels are compiled at launch, so the cost never lands on the first frames of a session - Talkify holds a user-initiated activity assertion while a **Direct Dictation** session or a **Drop Transcription** job runs, and holds none while idle: a menu-bar-only app with no window is what App Nap targets, and a napped process draws the HUD's animation clock too slowly to watch - The assertion is taken before the begin guards run, because the frames a napped process draws badly are the first ones; every guard that refuses a session releases it again @@ -157,8 +157,9 @@ _Avoid_: Transcript history, cloud analytics - The device that was lowered is the one restored, not whichever output is default when the session ends: the default can change mid-session, and the volume that needs putting back belongs to the device that was quieted - An output with no settable volume does not duck, and neither does one already at silence - A third voice visual, Compact, shows a small five-bar voice indicator beside the leading-aligned live draft inside the shape, after the iOS Dynamic Island caption look; the shape grows with the draft -- Compact is the only visual that shows the live draft while listening; Waveform and Edge Glow replace the draft text entirely -- An In the Shape live-draft placement for Waveform and Edge Glow was prototyped and removed; the draft-in-shape presentation belongs to Compact alone +- A fourth voice visual, Waveform + Draft, keeps Compact's leading live draft and wraps the shape in Edge Glow, with a slim Chart Line ribbon under the housing instead of the five-bar indicator; it has no particle cloud and no orb +- Compact and Waveform + Draft show the live draft while listening; Waveform and Edge Glow replace the draft text entirely +- An In the Shape live-draft placement for Waveform and Edge Glow was prototyped and removed; putting words on the concert waveform stays rejected. Waveform + Draft is the chosen live-draft direction: the draft has its own band under a slim ribbon, in one shape - A detached child pill under the island (the uni-pills goo bubble) was prototyped as a third placement and rejected by feel: a second detached surface does not fit the single-shape design - Reduce Motion overrides every live draft placement with the plain text band and quiet level meter - `AppSettings` is the only persisted source of truth for preferences; the Dictation controller creates Dictation session settings for the HUD at session start @@ -276,7 +277,7 @@ _Avoid_: Transcript history, cloud analytics - **Insights** shows words, sessions, speaking time, weighted words per minute, Voice Momentum, 14-day activity, streaks, and a 16-week heatmap - Settings lets the user choose the session sounds, mute them, adjust their volume, choose the voice-reactive visual, the waveform style, and the edge glow's color palette - Insertion latency must be benchmarked before choosing permanent per-application defaults -- Mid-session insertion was prototyped (streamed finalized chunks; a ghost overlay over the focused input) and rejected: insertion stays at session end, and no live-draft direction for Waveform and Edge Glow is chosen yet +- Mid-session insertion was prototyped (streamed finalized chunks; a ghost overlay over the focused input) and rejected: insertion stays at session end. The live-draft direction for combining Waveform and Edge Glow with the draft is Waveform + Draft, not mid-session insertion and not words over the concert waveform - A marked-text input method (system-dictation-style provisional text in any field) remains a considered route, deferred: it needs a separate installable input-source bundle, System Settings enablement, and cross-process wiring - **Read Aloud** speaks the focused application's selected text with Apple speech synthesis, on-device and offline; Siri voices are unavailable to third-party apps - Read Aloud reads the selection through Accessibility, and by copying it when Accessibility answers nothing, which is what every browser does; if neither finds anything it shows "No text selected" through the HUD and speaks nothing @@ -406,7 +407,7 @@ _Avoid_: Transcript history, cloud analytics - The sidebar retains the `SETTINGS` group label even when the header also shows Settings; it distinguishes navigation groups - The Talkify ghost is the app icon, the status item icon (template-rendered so it follows the menu bar theme), and the Settings header icon - While Direct Dictation listens, the status item ghost pulses between full and dimmed tint; the pulse stops when the session ends -- An Edge Glow session tints the status ghost pulse with its captured palette's accent; Waveform and Compact sessions keep the theme tint, and the idle ghost stays template-rendered +- An Edge Glow or Waveform + Draft session tints the status ghost pulse with its captured palette's accent; Waveform and Compact sessions keep the theme tint, and the idle ghost stays template-rendered - The Settings surface uses a 16-point continuous radius, a one-pixel low-opacity border, and a soft shadow - Settings may open during Direct Dictation; the active session remains on its captured settings and changes apply next session - While Direct Dictation is active, Settings shows a small contextual notice that changes apply to the next session; idle Settings has no notice @@ -424,4 +425,4 @@ _Avoid_: Transcript history, cloud analytics - "subtitles" was used for text displayed during a Google Meet call — resolved: this is **Live Captions**. - HUD behavior for long drafts — resolved: the downward-growing panel won the feel test against tail-only truncation and shrink-to-fit. The HUD wraps long drafts and grows downward, capped at four lines; the other two variants remain selectable for the Settings picker. -- The voice-reactive visual — resolved: both prototyped variants ship, user-selectable from Settings, and both replace draft text while listening. The waveform (default: the Chart Line conveyor with metallic silver treatment, plus seven alternate styles) fills the visual band; the edge glow is a three-part animation — a palette-gradient beam whose bright region blooms in from under the housing on session start, sweeps the shape's open silhouette while listening, follows the voice in brightness and stroke thickness (the feel-test pick over reach, particle count, particle size, and syllable bursts), and drains back when the session ends, a one-shot ripple wave rolling across the housing at session start, and a particle cloud in the palette's colors chasing the beam's sweeping origin. The palette (Spectrum, Silver, Aurora, Sunset, Ocean, Mono) is a Settings pick coloring beam and particles together. +- The voice-reactive visual — resolved: Waveform and Edge Glow ship and replace draft text while listening; Compact shows the live draft with a five-bar indicator; Waveform + Draft is the fourth pick, a designed combo of slim Chart Line, live draft, and Edge Glow chrome with no glow center. The waveform (default: the Chart Line conveyor with metallic silver treatment, plus seven alternate styles) fills the visual band; Waveform + Draft does not offer those styles. The edge glow is a three-part animation — a palette-gradient beam whose bright region blooms in from under the housing on session start, sweeps the shape's open silhouette while listening, follows the voice in brightness and stroke thickness (the feel-test pick over reach, particle count, particle size, and syllable bursts), and drains back when the session ends, a one-shot ripple wave rolling across the housing at session start, and a particle cloud in the palette's colors chasing the beam's sweeping origin. Waveform + Draft keeps the beam and the ripple and drops the center. The palette (Spectrum, Silver, Aurora, Sunset, Ocean, Mono) is a Settings pick coloring beam and particles together, and the shaping caption. diff --git a/Talkify/App/AppDelegate.swift b/Talkify/App/AppDelegate.swift index 9ea5b88..bf553d5 100644 --- a/Talkify/App/AppDelegate.swift +++ b/Talkify/App/AppDelegate.swift @@ -102,7 +102,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { dictationController.onRecordingStateChange = { [weak statusItemController, weak settingsRuntimeState] isRecording, session in let accent = session.flatMap { - $0.voiceVisual == .glow ? $0.glowPalette.statusAccent : nil + $0.voiceVisual.usesEdgeGlow ? $0.glowPalette.statusAccent : nil } statusItemController?.setRecording(isRecording, accent: accent) settingsRuntimeState?.isDictating = isRecording diff --git a/Talkify/App/AppSettings.swift b/Talkify/App/AppSettings.swift index 6030003..c00dc6d 100644 --- a/Talkify/App/AppSettings.swift +++ b/Talkify/App/AppSettings.swift @@ -470,11 +470,12 @@ struct DictationSessionSettings: Equatable { let hudMetrics: HUDMetrics /// The colour a Drop Transcription wears — on the HUD's target and card, and - /// on the status ghost while a file job fills it. Edge Glow lends its - /// palette's own hue; every other visual keeps Talkify blue. One definition, - /// so the shape and the menu bar can never drift apart (CONTEXT.md). + /// on the status ghost while a file job fills it. Edge Glow and Waveform + + /// Draft lend the palette's own hue; every other visual keeps Talkify blue. + /// One definition, so the shape and the menu bar can never drift apart + /// (CONTEXT.md). var dropAccent: NSColor { - voiceVisual == .glow ? glowPalette.statusAccent : SettingsTheme.accentColor + voiceVisual.usesEdgeGlow ? glowPalette.statusAccent : SettingsTheme.accentColor } @MainActor diff --git a/Talkify/CoreHUD/HUDMetrics.swift b/Talkify/CoreHUD/HUDMetrics.swift index 0a82e86..bc28182 100644 --- a/Talkify/CoreHUD/HUDMetrics.swift +++ b/Talkify/CoreHUD/HUDMetrics.swift @@ -27,15 +27,15 @@ struct HUDMetrics: Equatable { /// sizes beneath it are for the visuals that replace the draft entirely. static let minimumReadableScale: CGFloat = 0.4 - /// The floor for a given voice visual. Compact is built around the live - /// draft, and Reduce Motion restores the draft for every visual, so both stop - /// where the text stops being legible. Waveform and Edge Glow replace the - /// draft entirely and go all the way down. + /// The floor for a given voice visual. Compact and Waveform + Draft are + /// built around the live draft, and Reduce Motion restores the draft for + /// every visual, so those stop where the text stops being legible. Waveform + /// and Edge Glow replace the draft entirely and go all the way down. /// /// Read from the session's own settings rather than from what the HUD happens /// to be showing, so a shape cannot change size partway through a session. static func minimumScale(for visual: HUDVoiceVisualStyle, reduceMotion: Bool) -> CGFloat { - visual == .compact || reduceMotion ? minimumReadableScale : minimumScale + visual.showsDraftWhileListening || reduceMotion ? minimumReadableScale : minimumScale } /// The unscaled shape. The host window is sized from this, so the window diff --git a/Talkify/Dictation/HUD/DictationHUDController.swift b/Talkify/Dictation/HUD/DictationHUDController.swift index f8e9a1e..fe29010 100644 --- a/Talkify/Dictation/HUD/DictationHUDController.swift +++ b/Talkify/Dictation/HUD/DictationHUDController.swift @@ -95,12 +95,13 @@ final class DictationHUDController { /// What the band says before any words arrive. /// - /// Empty for Compact: it is the one visual built around the live draft, so a - /// placeholder there is words nobody spoke. Every path that would write one - /// asks here, rather than each deciding for itself — which is how "Listening - /// (latched)" kept coming back after the opening text was handled. + /// Empty for Compact and Waveform + Draft: both are built around the live + /// draft, so a placeholder there is words nobody spoke. Every path that + /// would write one asks here, rather than each deciding for itself — which + /// is how "Listening (latched)" kept coming back after the opening text was + /// handled. private var placeholder: String { - guard sessionSettings.voiceVisual != .compact else { return "" } + guard !sessionSettings.voiceVisual.showsDraftWhileListening else { return "" } return sessionIsLatched ? Self.latchedText : Self.listeningText } diff --git a/Talkify/Dictation/HUD/DictationHUDShellView.swift b/Talkify/Dictation/HUD/DictationHUDShellView.swift index a9f5a2f..10327b6 100644 --- a/Talkify/Dictation/HUD/DictationHUDShellView.swift +++ b/Talkify/Dictation/HUD/DictationHUDShellView.swift @@ -74,28 +74,28 @@ struct DictationHUDShellView: View { return nil } - /// Reduce Motion always shows the quiet level meter in its slim band; - /// otherwise both animated visuals get the tall band — the waveform fills + /// Reduce Motion always shows the quiet level meter in its slim band. + /// Compact has no band of its own. Waveform + Draft uses that same slim + /// strip for its Chart Line so strip + wrapped text still fit the fixed + /// window. Waveform and Edge Glow keep the tall band — the waveform fills /// it, the glow keeps it as an empty stage so the silhouette has flanks - /// for the light to wrap. In the Shape live draft trades band for text: - /// the glow drops the band entirely (the beam wraps the text band), the - /// waveform compresses to the slim strip so strip + wrapped text still - /// fit the fixed window. + /// for the light to wrap. private var visualBandHeight: CGFloat { guard keepsVisualLayout else { return 0 } if reduceMotion { return metrics.visualBandHeight } - // Compact has no band of its own: its indicator lives inside the - // text band, beside the draft. - if settings.voiceVisual == .compact { return 0 } - return metrics.waveBandHeight + switch settings.voiceVisual { + case .compact: return 0 + case .waveDraft: return metrics.visualBandHeight + case .waveform, .glow: return metrics.waveBandHeight + } } /// Waveform and Edge Glow replace the draft text entirely while - /// listening; Compact is built around it. With Reduce Motion the draft - /// text always shows. + /// listening; Compact and Waveform + Draft are built around it. With + /// Reduce Motion the draft text always shows. private var showsTextBand: Bool { if !keepsVisualLayout || reduceMotion { return true } - return settings.voiceVisual == .compact + return settings.voiceVisual.showsDraftWhileListening } /// Whether the bands stay as a listening session laid them out. Held through @@ -104,11 +104,16 @@ struct DictationHUDShellView: View { content.showsVoiceVisual || content.isDismissing } - /// Whether the text band renders the Compact layout: the voice indicator - /// beside a leading-aligned draft. Not gated on the listening state — - /// swapping the band's structure at finalize reads as a glitch mid - /// retract, so the layout stays and the indicator settles instead. - private var showsCompactBand: Bool { + /// Whether the text band is Compact's leading-aligned draft. Not gated on + /// the listening state — swapping the band's structure at finalize reads + /// as a glitch mid retract, so the layout stays and the indicator settles + /// instead. Waveform + Draft uses the same alignment without the five-bar + /// equalizer, because its Chart Line is already the voice. + private var showsLeadingDraft: Bool { + settings.voiceVisual.showsDraftWhileListening && !reduceMotion + } + + private var showsCompactIndicator: Bool { settings.voiceVisual == .compact && !reduceMotion } @@ -133,7 +138,7 @@ struct DictationHUDShellView: View { isRevealed: content.isRevealed, size: size, rippleTrigger: content.sessionEpoch, - rippleEnabled: settings.voiceVisual == .glow, + rippleEnabled: settings.voiceVisual.usesEdgeGlow, content: { bands // The tag hangs off the shell, not the text band: Waveform and Edge @@ -168,6 +173,8 @@ struct DictationHUDShellView: View { HUDLevelMeterView(content: content) } else if settings.voiceVisual == .waveform { HUDWaveformView(settings: settings, content: content) + } else if settings.voiceVisual == .waveDraft { + HUDCompactChartLineView(content: content, scale: metrics.scale) } else { // Edge Glow: the centers (particles, orb) are // shape-wide overlays, so the band is an empty stage. @@ -194,14 +201,17 @@ struct DictationHUDShellView: View { /// Draft text lives in the band below the housing. Compact puts its /// voice indicator on the leading side, Dynamic Island-style, with the - /// draft leading-aligned beside it; the other visuals center the draft. + /// draft leading-aligned beside it. Waveform + Draft keeps that + /// alignment without the indicator. The other visuals center the draft. @ViewBuilder private var textBand: some View { Group { - if showsCompactBand { + if showsLeadingDraft { HStack(alignment: .top, spacing: 10 * metrics.scale) { - HUDCompactIndicatorView(content: content, scale: metrics.scale) - .padding(.top, 3 * metrics.scale) + if showsCompactIndicator { + HUDCompactIndicatorView(content: content, scale: metrics.scale) + .padding(.top, 3 * metrics.scale) + } compactDraftText .frame(maxWidth: .infinity, alignment: .leading) } @@ -272,11 +282,25 @@ struct DictationHUDShellView: View { .padding(.horizontal, 4 * metrics.scale) .padding(.vertical, 1.5 * metrics.scale) .background(Capsule(style: .continuous).fill(.white.opacity(0.13))) - // Clears the housing, so a tag never sits beside the camera. - .padding(.top, HUDNotchGeometry.closedSize(for: screen).height + 5 * metrics.scale) + // Clears the housing, so a tag never sits beside the camera. When + // a slim visual band sits under the housing (Waveform + Draft, or + // Reduce Motion), the tag drops into the text band so it does not + // cover the ribbon. + .padding(.top, tagTopInset) .allowsHitTesting(false) } + private var tagTopInset: CGFloat { + let housing = HUDNotchGeometry.closedSize(for: screen).height + let belowHousing: CGFloat + if showsTextBand, visualBandHeight > 0 { + belowHousing = visualBandHeight + 5 * metrics.scale + } else { + belowHousing = 5 * metrics.scale + } + return housing + belowHousing + } + /// The Compact draft: the same long-draft semantics, leading-aligned so /// the text hangs off the indicator instead of floating centered. @ViewBuilder @@ -358,7 +382,7 @@ struct DictationHUDShellView: View { /// ends; the view disables its shader once the ramp reaches zero. @ViewBuilder private var edgeGlow: some View { - if !reduceMotion, settings.voiceVisual == .glow { + if !reduceMotion, settings.voiceVisual.usesEdgeGlow { HUDEdgeGlowView( content: content, settings: settings, @@ -402,3 +426,11 @@ struct DictationHUDShellView: View { HUDShellPreviewHarness(visual: .compact, shapingChoice: "Remove filler words") } +#Preview("Waveform + Draft") { + HUDShellPreviewHarness(visual: .waveDraft) +} + +#Preview("Waveform + Draft · dead mic") { + HUDShellPreviewHarness(visual: .waveDraft, micAlive: false) +} + diff --git a/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift b/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift new file mode 100644 index 0000000..ec3e745 --- /dev/null +++ b/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +/// The Waveform + Draft ribbon: Chart Line's conveyor in the slim 24-point +/// band, retuned so the line still reads at the 40% HUD-size floor. The +/// concert Chart Line's 6-point unscaled padding, −10 offset, reflection, +/// and 26-point under-glow would clip away in this strip. +struct HUDCompactChartLineView: View { + let content: DictationHUDContent + var scale: CGFloat = 1 + + @State private var samples: [Float] = [] + @State private var lastTick = Date.distantPast + @State private var tickInterval: Double = 0.022 + + private var live: Bool { + content.showsVoiceVisual && content.isAudioAlive + } + + var body: some View { + TimelineView(.animation(paused: !live)) { context in + let progress = min(1, max(0, context.date.timeIntervalSince(lastTick) / tickInterval)) + let level = content.audioLevel + let shape = SmoothLineShape(samples: samples, scrollProgress: live ? progress : 0) + let hairline = max(1, 1.2 * scale) + + if content.isAudioAlive { + ZStack { + shape + .stroke( + Self.silver, + style: StrokeStyle( + lineWidth: (2.2 + 3 * level) * scale, + lineCap: .round, + lineJoin: .round + ) + ) + .blur(radius: 3 * scale) + .opacity(0.25 + 0.45 * level) + shape + .stroke( + .white.opacity(0.8 + 0.2 * level), + style: StrokeStyle(lineWidth: hairline, lineCap: .round, lineJoin: .round) + ) + } + } else { + shape + .stroke( + HUDVisualTokens.deadMicAmber.opacity(0.85), + style: StrokeStyle(lineWidth: hairline, lineCap: .round, lineJoin: .round) + ) + } + } + .padding(.horizontal, 10 * scale) + .clipped() + .onAppear { samples = content.levelHistory } + .onChange(of: content.levelHistory) { _, new in + let now = Date() + let gap = now.timeIntervalSince(lastTick) + if gap < 0.1 { + tickInterval = tickInterval * 0.8 + gap * 0.2 + } + samples = new + lastTick = now + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + /// Same metallic silver as the concert Chart Line, so the ribbon is + /// recognisably that treatment rather than a second product. Palette + /// colour stays on the edge beam. + private static let silver = LinearGradient( + colors: [ + Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), + .white, + Color(red: 0.82, green: 0.86, blue: 0.95), + .white, + Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), + ], + startPoint: .leading, + endPoint: .trailing + ) +} diff --git a/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift b/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift index b5340a7..3ff2939 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift @@ -53,7 +53,7 @@ struct HUDEdgeGlowView: View { var body: some View { GeometryReader { proxy in let listening = content.showsVoiceVisual - TimelineView(.animation(paused: !listening)) { context in + TimelineView(.animation(paused: !listening || !content.isAudioAlive)) { context in // Plain values for the @Sendable keyframeAnimator content // closure; the body re-evaluates on every level tick and // timeline frame, so they stay fresh. diff --git a/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift b/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift index 84197fb..bc0b135 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift @@ -1,10 +1,15 @@ /// The voice-reactive visual shown while listening, user-selectable from -/// Settings. Waveform and Edge Glow replace the draft text unless the Live -/// draft pick keeps it; Compact is built around the draft text. Reduce -/// Motion overrides all of them with a quiet level meter. +/// Settings. Waveform and Edge Glow replace the draft text; Compact and +/// Waveform + Draft keep it. Reduce Motion overrides all of them with a +/// quiet level meter. enum HUDVoiceVisualStyle: String, CaseIterable { /// A Metal shader waveform that fills the HUD's visual band. case waveform = "Waveform" + /// Slim Chart Line under the housing, Compact's live draft below it, and + /// Edge Glow on the silhouette. No five-bar indicator, particle cloud, or + /// orb — those sit on the words. The concert waveform styles stay on + /// Waveform; this composition is one designed Chart Line. + case waveDraft = "Waveform + Draft" /// An origin glow hugging the shape's edge: it blooms out of the notch /// housing on session start, breathes with the voice, and drains back /// when the session ends. Its center content is a separate pick @@ -14,4 +19,15 @@ enum HUDVoiceVisualStyle: String, CaseIterable { /// live draft text inside the shape (HUDCompactIndicatorView); the shape /// grows with the draft. case compact = "Compact" + + /// Compact and Waveform + Draft are built around the live draft, so a + /// placeholder would be words nobody spoke. + var showsDraftWhileListening: Bool { + self == .compact || self == .waveDraft + } + + /// The beam, start ripple, shaping caption, and status-ghost accent. + var usesEdgeGlow: Bool { + self == .glow || self == .waveDraft + } } diff --git a/Talkify/DropTranscription/HUD/DropHUDStyle.swift b/Talkify/DropTranscription/HUD/DropHUDStyle.swift index 49260b5..9cfd603 100644 --- a/Talkify/DropTranscription/HUD/DropHUDStyle.swift +++ b/Talkify/DropTranscription/HUD/DropHUDStyle.swift @@ -7,11 +7,11 @@ struct DropHUDStyle { let metrics: HUDMetrics /// The accent for glyphs, the peek bar, and the notice. /// - /// The drop surfaces speak the voice visual's colour: Edge Glow lends its - /// palette — the same hue the status ghost takes during a glow session, so - /// one palette means one colour everywhere — and every other visual keeps - /// the Talkify blue. Settings chrome is unaffected either way; the palette - /// colours the HUD, never the app (CONTEXT.md). + /// The drop surfaces speak the voice visual's colour: Edge Glow and + /// Waveform + Draft lend the palette — the same hue the status ghost takes + /// during those sessions, so one palette means one colour everywhere — and + /// every other visual keeps the Talkify blue. Settings chrome is unaffected + /// either way; the palette colours the HUD, never the app (CONTEXT.md). let accent: Color /// The receptive edge gets the palette's whole gradient rather than one hue /// of it: it is the one element long enough to show a palette off. @@ -22,7 +22,7 @@ struct DropHUDStyle { init(settings: DictationSessionSettings) { metrics = settings.hudMetrics accent = Color(nsColor: settings.dropAccent) - accentStroke = settings.voiceVisual == .glow + accentStroke = settings.voiceVisual.usesEdgeGlow ? settings.glowPalette.stroke : AnyShapeStyle(SettingsTheme.accent) } diff --git a/Talkify/Settings/Sections/AppearanceSettingsView.swift b/Talkify/Settings/Sections/AppearanceSettingsView.swift index 54bbc47..6895121 100644 --- a/Talkify/Settings/Sections/AppearanceSettingsView.swift +++ b/Talkify/Settings/Sections/AppearanceSettingsView.swift @@ -1,20 +1,27 @@ import SwiftUI /// The Appearance section: the live preview first, then the Voice visual -/// and Motion and layout groups (CONTEXT.md). Waveform and Glow rows are -/// conditional on the selected visual; hidden values stay persisted. +/// and Motion and layout groups (CONTEXT.md). Waveform, palette, and glow +/// center rows are conditional on the selected visual; hidden values stay +/// persisted. struct AppearanceSettingsView: View { @Bindable var settings: AppSettings @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Which conditional rows the selected visual exposes; hidden values - /// stay persisted (CONTEXT.md). + /// stay persisted (CONTEXT.md). Waveform + Draft is a designed Chart + /// Line, so it does not offer the concert style picker. It takes the + /// glow palette (beam, shaping caption, status ghost) but not a center. static func showsWaveformOptions(for visual: HUDVoiceVisualStyle) -> Bool { visual == .waveform } - static func showsGlowOptions(for visual: HUDVoiceVisualStyle) -> Bool { + static func showsGlowPalette(for visual: HUDVoiceVisualStyle) -> Bool { + visual.usesEdgeGlow + } + + static func showsGlowCenter(for visual: HUDVoiceVisualStyle) -> Bool { visual == .glow } @@ -41,7 +48,7 @@ struct AppearanceSettingsView: View { ) } - if Self.showsGlowOptions(for: settings.voiceVisual) { + if Self.showsGlowPalette(for: settings.voiceVisual) { SettingsPickerRow( title: "Glow palette", description: "The colors used by the edge beam", @@ -49,7 +56,9 @@ struct AppearanceSettingsView: View { optionLabel: { $0.rawValue }, selection: $settings.glowPalette ) + } + if Self.showsGlowCenter(for: settings.voiceVisual) { SettingsPickerRow( title: "Glow center", description: "The visual inside the edge beam", diff --git a/TalkifyTests/AppSettingsTests.swift b/TalkifyTests/AppSettingsTests.swift index 5fbb013..7912db8 100644 --- a/TalkifyTests/AppSettingsTests.swift +++ b/TalkifyTests/AppSettingsTests.swift @@ -80,6 +80,8 @@ struct AppSettingsTests { #expect(defaults.object(forKey: "dictationSoundsEnabled") as? Bool == false) #expect(defaults.double(forKey: "dictationSoundVolume") == 0.2) #expect(defaults.string(forKey: "hudVoiceVisual") == "Edge Glow") + settings.voiceVisual = .waveDraft + #expect(defaults.string(forKey: "hudVoiceVisual") == "Waveform + Draft") #expect(defaults.string(forKey: "hudWaveformStyle") == "Silver") #expect(defaults.string(forKey: "hudRevealStyle") == "Drift") #expect(defaults.string(forKey: "hudLongDraftStyle") == "Tail Only") diff --git a/TalkifyTests/HUDMetricsTests.swift b/TalkifyTests/HUDMetricsTests.swift index 75cebe0..5590b47 100644 --- a/TalkifyTests/HUDMetricsTests.swift +++ b/TalkifyTests/HUDMetricsTests.swift @@ -30,11 +30,12 @@ struct HUDMetricsTests { #expect(HUDMetrics(scale: 0).scale == HUDMetrics.minimumScale) } - /// Compact is the only visual built around the live draft, so it is the only - /// one that gives up the smallest sizes. Reduce Motion restores the draft for - /// every visual and takes them away from all three. - @Test func onlyCompactAndReduceMotionGiveUpTheSmallestSizes() { + /// Compact and Waveform + Draft are built around the live draft, so they + /// give up the smallest sizes. Reduce Motion restores the draft for every + /// visual and takes those sizes away from all of them. + @Test func draftVisualsAndReduceMotionGiveUpTheSmallestSizes() { #expect(HUDMetrics.minimumScale(for: .compact, reduceMotion: false) == 0.4) + #expect(HUDMetrics.minimumScale(for: .waveDraft, reduceMotion: false) == 0.4) #expect(HUDMetrics.minimumScale(for: .waveform, reduceMotion: false) == 0.2) #expect(HUDMetrics.minimumScale(for: .glow, reduceMotion: false) == 0.2) #expect(HUDMetrics.minimumScale(for: .waveform, reduceMotion: true) == 0.4) @@ -109,6 +110,7 @@ struct HUDShellMetricsTests { @Test func aDraftLayoutIsHeldAtTheReadableFloorAndOthersAreNot() { #expect(scale(picked: 0.2, visual: .compact) == HUDMetrics.minimumReadableScale) + #expect(scale(picked: 0.2, visual: .waveDraft) == HUDMetrics.minimumReadableScale) #expect(scale(picked: 0.2, visual: .waveform) == HUDMetrics.minimumScale) #expect(scale(picked: 0.7, visual: .compact) == 0.7) } diff --git a/TalkifyTests/HUDNotchGeometryTests.swift b/TalkifyTests/HUDNotchGeometryTests.swift index 9b14926..b68076d 100644 --- a/TalkifyTests/HUDNotchGeometryTests.swift +++ b/TalkifyTests/HUDNotchGeometryTests.swift @@ -142,6 +142,34 @@ struct HUDNotchGeometryTests { #expect(tallest <= window.height - HUDNotchGeometry.shadowPadding) } + /// Waveform + Draft is that same tallest stack: slim Chart Line, Grow Down + /// cap, shaping caption. The concert 64-point waveform on top of the draft + /// would not fit. + @Test func waveDraftMaximumStackFitsTheFixedWindow() { + let window = HUDNotchGeometry.windowFrame(for: notched, clearsMenuBar: false) + let content = HUDNotchGeometry.contentSize( + for: notched, + metrics: .standard, + visualBandHeight: HUDMetrics.standard.visualBandHeight, + includesTextBand: true, + shapingBandHeight: HUDMetrics.standard.shapingBandHeight + ) + let growDown = 32 + + HUDMetrics.standard.visualBandHeight + + HUDMetrics.standard.maxTextBandHeight + + HUDMetrics.standard.shapingBandHeight + #expect(content.height == 32 + + HUDMetrics.standard.visualBandHeight + + HUDMetrics.standard.textBandHeight + + HUDMetrics.standard.shapingBandHeight) + #expect(growDown <= window.height - HUDNotchGeometry.shadowPadding) + let concertPlusDraft = 32 + + HUDMetrics.standard.waveBandHeight + + HUDMetrics.standard.maxTextBandHeight + + HUDMetrics.standard.shapingBandHeight + #expect(concertPlusDraft > window.height - HUDNotchGeometry.shadowPadding) + } + /// Issue #83: a real notch already sits in its own housing, clear of /// wherever the system draws status items, so there is nothing for the /// shape to hide there — it keeps hugging the true top edge. diff --git a/TalkifyTests/HUDPlaceholderTests.swift b/TalkifyTests/HUDPlaceholderTests.swift index 9d7a5ed..780c27e 100644 --- a/TalkifyTests/HUDPlaceholderTests.swift +++ b/TalkifyTests/HUDPlaceholderTests.swift @@ -3,10 +3,10 @@ import Testing @testable import Talkify -/// Compact is built around the live draft, so it opens with nothing. Three -/// separate paths write the placeholder — opening, latching, and coming back -/// from a model download — and fixing only the first left "Listening (latched)" -/// still appearing. +/// Compact and Waveform + Draft are built around the live draft, so they +/// open with nothing. Three separate paths write the placeholder — opening, +/// latching, and coming back from a model download — and fixing only the +/// first left "Listening (latched)" still appearing. @MainActor @Suite("HUD placeholder text") struct HUDPlaceholderTests { @@ -20,9 +20,10 @@ struct HUDPlaceholderTests { store.sessionSettings } - @Test func compactOpensWithNoText() { + @Test(arguments: [HUDVoiceVisualStyle.compact, .waveDraft]) + func aDraftVisualOpensWithNoText(visual: HUDVoiceVisualStyle) { let store = AppSettings.previewStore() - store.voiceVisual = .compact + store.voiceVisual = visual let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store) hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store)) @@ -35,11 +36,12 @@ struct HUDPlaceholderTests { #expect(hud.textForTesting.isEmpty) } - /// A latched Compact session still says nothing, which is the case that was + /// A latched draft visual still says nothing, which is the case that was /// reported after the opening text was already handled. - @Test func compactLatchesWithNoText() { + @Test(arguments: [HUDVoiceVisualStyle.compact, .waveDraft]) + func aDraftVisualLatchesWithNoText(visual: HUDVoiceVisualStyle) { let store = AppSettings.previewStore() - store.voiceVisual = .compact + store.voiceVisual = visual let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store) hud.showListening(on: CGDirectDisplayID?.none, isLatched: true, settings: session(store)) diff --git a/TalkifyTests/SettingsWindowTests.swift b/TalkifyTests/SettingsWindowTests.swift index b8448aa..e2b85d2 100644 --- a/TalkifyTests/SettingsWindowTests.swift +++ b/TalkifyTests/SettingsWindowTests.swift @@ -55,9 +55,14 @@ struct SettingsWindowTests { #expect(AppearanceSettingsView.showsWaveformOptions(for: .waveform)) #expect(!AppearanceSettingsView.showsWaveformOptions(for: .glow)) #expect(!AppearanceSettingsView.showsWaveformOptions(for: .compact)) - #expect(AppearanceSettingsView.showsGlowOptions(for: .glow)) - #expect(!AppearanceSettingsView.showsGlowOptions(for: .waveform)) - #expect(!AppearanceSettingsView.showsGlowOptions(for: .compact)) + #expect(!AppearanceSettingsView.showsWaveformOptions(for: .waveDraft)) + #expect(AppearanceSettingsView.showsGlowPalette(for: .glow)) + #expect(AppearanceSettingsView.showsGlowPalette(for: .waveDraft)) + #expect(!AppearanceSettingsView.showsGlowPalette(for: .waveform)) + #expect(!AppearanceSettingsView.showsGlowPalette(for: .compact)) + #expect(AppearanceSettingsView.showsGlowCenter(for: .glow)) + #expect(!AppearanceSettingsView.showsGlowCenter(for: .waveDraft)) + #expect(!AppearanceSettingsView.showsGlowCenter(for: .waveform)) } @Test func releaseMetadataExcludesUnlicensedOptions() { diff --git a/docs/design/FEATURE.md b/docs/design/FEATURE.md index ee9c5c4..92676db 100644 --- a/docs/design/FEATURE.md +++ b/docs/design/FEATURE.md @@ -48,12 +48,13 @@ the notch, nothing inside it. **Open** is the real target. Open shows a media type glyph, the file name, and nothing else. The edge carries a dashed accent stroke, so it reads as live and receptive. -The accent is not fixed. It follows the voice visual: with Edge Glow selected -the target wears that palette — the full gradient on its edge, where there is -length enough to show a palette off, and the palette's one representative hue -on the glyph, the peek bar and the closing line. Sunset makes an orange target; -Aurora a green one. Every other visual keeps the Talkify blue. It is the same -hue the status ghost takes during a glow session, so a palette means one colour +The accent is not fixed. It follows the voice visual: with Edge Glow or +Waveform + Draft selected the target wears that palette — the full gradient +on its edge, where there is length enough to show a palette off, and the +palette's one representative hue on the glyph, the peek bar and the closing +line. Sunset makes an orange target; Aurora a green one. Every other visual +keeps the Talkify blue. It is the same hue the status ghost takes during a +glow session, so a palette means one colour across the whole app — except Settings chrome, which stays blue on purpose. The peek bar breathes for the same reason: a pointer crossing the top of the screen catches movement, never a static tab. diff --git a/docs/design/README.md b/docs/design/README.md index 957d5db..277afca 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -16,7 +16,7 @@ bar, and a dark modal Settings window. | `design-system.md` | Color, type, spacing, radius, elevation, contrast | | `hud-anatomy.md` | The notch island: geometry, bands, states, hard rules | | `motion.md` | Every animation with its real spring and duration | -| `voice-visuals.md` | The three voice visuals and their palettes | +| `voice-visuals.md` | The four voice visuals and their palettes | | `settings-ui.md` | The Settings modal and its component set | | `FEATURE.md` | **Drop Transcription** — the feature being designed | diff --git a/docs/design/hud-anatomy.md b/docs/design/hud-anatomy.md index aa4a545..0e4a719 100644 --- a/docs/design/hud-anatomy.md +++ b/docs/design/hud-anatomy.md @@ -58,15 +58,17 @@ height. Nothing is ever drawn here — content would sit behind the camera. **Visual band.** Holds the voice visual. Its height depends on which visual is selected: `64 * scale` for Waveform, `64 * scale` (empty, used as a stage -for edge-hugging light) for Edge Glow, `24 * scale` for the Reduce Motion -level meter, and zero for Compact, whose indicator lives inside the text band -instead. - -**Text band.** Holds live draft text. Present for Compact, absent for -Waveform and Edge Glow while listening, and always present under Reduce -Motion. Draft text is 15pt medium white, centered, with three long-draft -behaviors the user picks between: single line with head truncation, wrap and -grow downward capped at four lines, or single line that shrinks to fit. +for edge-hugging light) for Edge Glow, `24 * scale` for Waveform + Draft's +slim Chart Line and for the Reduce Motion level meter, and zero for Compact, +whose indicator lives inside the text band instead. + +**Text band.** Holds live draft text. Present for Compact and Waveform + +Draft, absent for Waveform and Edge Glow while listening, and always present +under Reduce Motion. Draft text is 15pt medium white, centered except on +Compact and Waveform + Draft where it is leading-aligned, with three +long-draft behaviors the user picks between: single line with head +truncation, wrap and grow downward capped at four lines, or single line that +shrinks to fit. **Language tag.** A small capsule pinned to the top leading corner below the housing, shown only when a second dictation language is configured. It hangs diff --git a/docs/design/voice-visuals.md b/docs/design/voice-visuals.md index 1cc6e9e..2f77811 100644 --- a/docs/design/voice-visuals.md +++ b/docs/design/voice-visuals.md @@ -2,14 +2,14 @@ Source: `Talkify/Dictation/HUD/Visuals/`, `Talkify/CoreHUD/Shaders/`. -The animated thing inside the island that reacts to the user's voice. Three +The animated thing inside the island that reacts to the user's voice. Four of them, user-selectable, default Waveform. Rendering is Metal for the heavy ones (ADR-0002). This document exists mostly so a new surface knows what visual language it is joining, and what colors are already spoken for. -## The three +## The four **Waveform** — a shader waveform filling the `64 * scale` visual band. Nine sub-styles the user picks between: Article, Silver, Capsules, Chart Line @@ -27,10 +27,18 @@ silhouette has flanks for light to wrap around. Also replaces draft text. **Compact** — a five-bar indicator beside leading-aligned live draft text, after the iOS Dynamic Island caption look. Bars are 2.5pt wide capsules, -2.5pt apart, resting at 3pt and reaching about 14pt. This is the only visual -that shows the draft while listening, and the shape grows with the text. - -Under Reduce Motion all three are replaced by a quiet level meter in the slim +2.5pt apart, resting at 3pt and reaching about 14pt. The shape grows with +the text. + +**Waveform + Draft** — Compact's leading live draft with a slim Chart Line +ribbon in the `24 * scale` band and Edge Glow on the silhouette. No five-bar +indicator, particle cloud, or orb. The concert waveform styles stay on +Waveform; this pick is one designed Chart Line. Glow palette colours the +beam, the shaping caption, and the status ghost. Putting the draft on top of +the 64-point waveform was prototyped and removed; this is the other +direction: the draft has its own band. + +Under Reduce Motion all four are replaced by a quiet level meter in the slim `24 * scale` band, and the draft text always shows. ## Edge Glow palettes diff --git a/docs/settings-ui-spec.md b/docs/settings-ui-spec.md index 9a5b6b5..31eb5e6 100644 --- a/docs/settings-ui-spec.md +++ b/docs/settings-ui-spec.md @@ -47,9 +47,10 @@ The content pane must update in place when a navigation item is selected. The se ### Appearance -- Voice visual: Waveform or Edge Glow. +- Voice visual: Waveform, Waveform + Draft, Edge Glow, or Compact. - Waveform style, shown only for Waveform. -- Glow palette and glow center, shown only for Edge Glow. +- Glow palette, shown for Edge Glow and Waveform + Draft. +- Glow center, shown only for Edge Glow. - Reveal style. - Long draft behavior. - Reduce Motion, using the system setting as the default. From 0a11e2fab8ff115b583041c45562518ebc565bbb Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Thu, 3 Sep 2026 20:15:50 -0700 Subject: [PATCH 02/11] Tuck the Waveform + Draft ribbon under the housing The Chart Line shares the text band instead of taking a 24-point strip of its own, so the words start higher and keep the height that band used. --- CONTEXT.md | 2 +- Talkify/CoreHUD/HUDMetrics.swift | 4 ++ .../Dictation/HUD/DictationHUDShellView.swift | 53 ++++++++++++------- .../HUD/Visuals/HUDCompactChartLineView.swift | 12 ++--- TalkifyTests/HUDMetricsTests.swift | 2 + TalkifyTests/HUDNotchGeometryTests.swift | 11 ++-- docs/design/hud-anatomy.md | 7 +-- docs/design/voice-visuals.md | 15 +++--- 8 files changed, 65 insertions(+), 41 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 089cf17..cbcd812 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -157,7 +157,7 @@ _Avoid_: Transcript history, cloud analytics - The device that was lowered is the one restored, not whichever output is default when the session ends: the default can change mid-session, and the volume that needs putting back belongs to the device that was quieted - An output with no settable volume does not duck, and neither does one already at silence - A third voice visual, Compact, shows a small five-bar voice indicator beside the leading-aligned live draft inside the shape, after the iOS Dynamic Island caption look; the shape grows with the draft -- A fourth voice visual, Waveform + Draft, keeps Compact's leading live draft and wraps the shape in Edge Glow, with a slim Chart Line ribbon under the housing instead of the five-bar indicator; it has no particle cloud and no orb +- A fourth voice visual, Waveform + Draft, keeps Compact's leading live draft and wraps the shape in Edge Glow, with a slim Chart Line ribbon at the top of the text band instead of the five-bar indicator; it has no particle cloud and no orb - Compact and Waveform + Draft show the live draft while listening; Waveform and Edge Glow replace the draft text entirely - An In the Shape live-draft placement for Waveform and Edge Glow was prototyped and removed; putting words on the concert waveform stays rejected. Waveform + Draft is the chosen live-draft direction: the draft has its own band under a slim ribbon, in one shape - A detached child pill under the island (the uni-pills goo bubble) was prototyped as a third placement and rejected by feel: a second detached surface does not fit the single-shape design diff --git a/Talkify/CoreHUD/HUDMetrics.swift b/Talkify/CoreHUD/HUDMetrics.swift index bc28182..bf9704b 100644 --- a/Talkify/CoreHUD/HUDMetrics.swift +++ b/Talkify/CoreHUD/HUDMetrics.swift @@ -64,6 +64,10 @@ struct HUDMetrics: Equatable { /// shown between the housing and the text band. var visualBandHeight: CGFloat { 24 * scale } + /// Height of the Waveform + Draft Chart Line, drawn at the top of the + /// text band rather than as a band of its own, so the words sit higher. + var ribbonBandHeight: CGFloat { 12 * scale } + /// Height of the waveform band. The waveform variant replaces the draft /// text entirely, so it gets room to breathe. var waveBandHeight: CGFloat { 64 * scale } diff --git a/Talkify/Dictation/HUD/DictationHUDShellView.swift b/Talkify/Dictation/HUD/DictationHUDShellView.swift index 10327b6..17c518e 100644 --- a/Talkify/Dictation/HUD/DictationHUDShellView.swift +++ b/Talkify/Dictation/HUD/DictationHUDShellView.swift @@ -75,17 +75,16 @@ struct DictationHUDShellView: View { } /// Reduce Motion always shows the quiet level meter in its slim band. - /// Compact has no band of its own. Waveform + Draft uses that same slim - /// strip for its Chart Line so strip + wrapped text still fit the fixed - /// window. Waveform and Edge Glow keep the tall band — the waveform fills - /// it, the glow keeps it as an empty stage so the silhouette has flanks - /// for the light to wrap. + /// Compact and Waveform + Draft have no band of their own: Compact's + /// indicator and Waveform + Draft's Chart Line live inside the text band, + /// so the words start higher. Waveform and Edge Glow keep the tall band — + /// the waveform fills it, the glow keeps it as an empty stage so the + /// silhouette has flanks for the light to wrap. private var visualBandHeight: CGFloat { guard keepsVisualLayout else { return 0 } if reduceMotion { return metrics.visualBandHeight } switch settings.voiceVisual { - case .compact: return 0 - case .waveDraft: return metrics.visualBandHeight + case .compact, .waveDraft: return 0 case .waveform, .glow: return metrics.waveBandHeight } } @@ -173,8 +172,6 @@ struct DictationHUDShellView: View { HUDLevelMeterView(content: content) } else if settings.voiceVisual == .waveform { HUDWaveformView(settings: settings, content: content) - } else if settings.voiceVisual == .waveDraft { - HUDCompactChartLineView(content: content, scale: metrics.scale) } else { // Edge Glow: the centers (particles, orb) are // shape-wide overlays, so the band is an empty stage. @@ -201,12 +198,21 @@ struct DictationHUDShellView: View { /// Draft text lives in the band below the housing. Compact puts its /// voice indicator on the leading side, Dynamic Island-style, with the - /// draft leading-aligned beside it. Waveform + Draft keeps that - /// alignment without the indicator. The other visuals center the draft. + /// draft leading-aligned beside it. Waveform + Draft puts a thin Chart + /// Line flush under the housing and keeps that alignment, so the words + /// get the strip a dedicated ribbon band would have occupied. The other + /// visuals center the draft. @ViewBuilder private var textBand: some View { Group { - if showsLeadingDraft { + if showsWaveDraftRibbon { + VStack(alignment: .leading, spacing: 4 * metrics.scale) { + HUDCompactChartLineView(content: content, scale: metrics.scale) + .frame(height: metrics.ribbonBandHeight) + compactDraftText + .frame(maxWidth: .infinity, alignment: .leading) + } + } else if showsLeadingDraft { HStack(alignment: .top, spacing: 10 * metrics.scale) { if showsCompactIndicator { HUDCompactIndicatorView(content: content, scale: metrics.scale) @@ -224,8 +230,18 @@ struct DictationHUDShellView: View { // The tag sits in the inset rather than in the flow, and the inset grows // on both sides, so centered drafts stay centered and nothing overlaps. .padding(.horizontal, tagInset * metrics.scale) - .padding(.vertical, 9 * metrics.scale) - .frame(minHeight: metrics.textBandHeight) + .padding(.top, showsWaveDraftRibbon ? 4 * metrics.scale : 9 * metrics.scale) + .padding(.bottom, 9 * metrics.scale) + // Waveform + Draft folds the old 24-point ribbon band into this + // minimum so the island does not shrink and the extra height is text. + .frame( + minHeight: metrics.textBandHeight + + (showsWaveDraftRibbon ? metrics.visualBandHeight : 0) + ) + } + + private var showsWaveDraftRibbon: Bool { + settings.voiceVisual == .waveDraft && !reduceMotion } /// The room the language tag needs on each side. @@ -283,9 +299,8 @@ struct DictationHUDShellView: View { .padding(.vertical, 1.5 * metrics.scale) .background(Capsule(style: .continuous).fill(.white.opacity(0.13))) // Clears the housing, so a tag never sits beside the camera. When - // a slim visual band sits under the housing (Waveform + Draft, or - // Reduce Motion), the tag drops into the text band so it does not - // cover the ribbon. + // Waveform + Draft's ribbon or Reduce Motion's meter sits under the + // housing, the tag drops below that strip so it does not cover it. .padding(.top, tagTopInset) .allowsHitTesting(false) } @@ -293,7 +308,9 @@ struct DictationHUDShellView: View { private var tagTopInset: CGFloat { let housing = HUDNotchGeometry.closedSize(for: screen).height let belowHousing: CGFloat - if showsTextBand, visualBandHeight > 0 { + if showsWaveDraftRibbon { + belowHousing = 4 * metrics.scale + metrics.ribbonBandHeight + 4 * metrics.scale + } else if showsTextBand, visualBandHeight > 0 { belowHousing = visualBandHeight + 5 * metrics.scale } else { belowHousing = 5 * metrics.scale diff --git a/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift b/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift index ec3e745..4b4033b 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift @@ -1,9 +1,9 @@ import SwiftUI -/// The Waveform + Draft ribbon: Chart Line's conveyor in the slim 24-point -/// band, retuned so the line still reads at the 40% HUD-size floor. The -/// concert Chart Line's 6-point unscaled padding, −10 offset, reflection, -/// and 26-point under-glow would clip away in this strip. +/// The Waveform + Draft ribbon: Chart Line's conveyor in a 12-point strip +/// at the top of the draft, retuned so the line still reads at the 40% +/// HUD-size floor. The concert Chart Line's 6-point unscaled padding, −10 +/// offset, reflection, and 26-point under-glow would clip away in this strip. struct HUDCompactChartLineView: View { let content: DictationHUDContent var scale: CGFloat = 1 @@ -29,12 +29,12 @@ struct HUDCompactChartLineView: View { .stroke( Self.silver, style: StrokeStyle( - lineWidth: (2.2 + 3 * level) * scale, + lineWidth: (1.4 + 2 * level) * scale, lineCap: .round, lineJoin: .round ) ) - .blur(radius: 3 * scale) + .blur(radius: 2 * scale) .opacity(0.25 + 0.45 * level) shape .stroke( diff --git a/TalkifyTests/HUDMetricsTests.swift b/TalkifyTests/HUDMetricsTests.swift index 5590b47..88111cf 100644 --- a/TalkifyTests/HUDMetricsTests.swift +++ b/TalkifyTests/HUDMetricsTests.swift @@ -7,6 +7,7 @@ struct HUDMetricsTests { #expect(HUDMetrics.standard.scale == 1) #expect(HUDMetrics.standard.contentWidth == 540) #expect(HUDMetrics.standard.waveBandHeight == 64) + #expect(HUDMetrics.standard.ribbonBandHeight == 12) } @Test func everyDimensionScalesTogether() { @@ -18,6 +19,7 @@ struct HUDMetricsTests { #expect(half.maxTextBandHeight == full.maxTextBandHeight * 0.8) #expect(half.visualBandHeight == full.visualBandHeight * 0.8) #expect(half.waveBandHeight == full.waveBandHeight * 0.8) + #expect(half.ribbonBandHeight == full.ribbonBandHeight * 0.8) #expect(half.bottomCornerRadius == full.bottomCornerRadius * 0.8) } diff --git a/TalkifyTests/HUDNotchGeometryTests.swift b/TalkifyTests/HUDNotchGeometryTests.swift index b68076d..e8e637f 100644 --- a/TalkifyTests/HUDNotchGeometryTests.swift +++ b/TalkifyTests/HUDNotchGeometryTests.swift @@ -142,24 +142,23 @@ struct HUDNotchGeometryTests { #expect(tallest <= window.height - HUDNotchGeometry.shadowPadding) } - /// Waveform + Draft is that same tallest stack: slim Chart Line, Grow Down - /// cap, shaping caption. The concert 64-point waveform on top of the draft - /// would not fit. + /// Waveform + Draft has no dedicated visual band: the ribbon lives in the + /// text band. Grow Down plus shaping still fits; a concert waveform on top + /// of the draft would not. @Test func waveDraftMaximumStackFitsTheFixedWindow() { let window = HUDNotchGeometry.windowFrame(for: notched, clearsMenuBar: false) let content = HUDNotchGeometry.contentSize( for: notched, metrics: .standard, - visualBandHeight: HUDMetrics.standard.visualBandHeight, + visualBandHeight: 0, includesTextBand: true, shapingBandHeight: HUDMetrics.standard.shapingBandHeight ) let growDown = 32 - + HUDMetrics.standard.visualBandHeight + + HUDMetrics.standard.ribbonBandHeight + HUDMetrics.standard.maxTextBandHeight + HUDMetrics.standard.shapingBandHeight #expect(content.height == 32 - + HUDMetrics.standard.visualBandHeight + HUDMetrics.standard.textBandHeight + HUDMetrics.standard.shapingBandHeight) #expect(growDown <= window.height - HUDNotchGeometry.shadowPadding) diff --git a/docs/design/hud-anatomy.md b/docs/design/hud-anatomy.md index 0e4a719..7bbdb98 100644 --- a/docs/design/hud-anatomy.md +++ b/docs/design/hud-anatomy.md @@ -37,6 +37,7 @@ detached tabs. | Text band height | `36 * scale` min, `120 * scale` max | yes | | Visual band, Reduce Motion | `24 * scale` | yes | | Visual band, waveform / glow | `64 * scale` | yes | +| Waveform + Draft ribbon | `12 * scale`, inside the text band | yes | | Bottom corner radius | `20 * scale` | yes | | Housing, measured notch | whatever the display reports | **no** | | Housing, simulated | `185 × 32` | **no** | @@ -58,9 +59,9 @@ height. Nothing is ever drawn here — content would sit behind the camera. **Visual band.** Holds the voice visual. Its height depends on which visual is selected: `64 * scale` for Waveform, `64 * scale` (empty, used as a stage -for edge-hugging light) for Edge Glow, `24 * scale` for Waveform + Draft's -slim Chart Line and for the Reduce Motion level meter, and zero for Compact, -whose indicator lives inside the text band instead. +for edge-hugging light) for Edge Glow, `24 * scale` for the Reduce Motion +level meter, and zero for Compact and Waveform + Draft, whose indicator and +Chart Line ribbon live inside the text band instead. **Text band.** Holds live draft text. Present for Compact and Waveform + Draft, absent for Waveform and Edge Glow while listening, and always present diff --git a/docs/design/voice-visuals.md b/docs/design/voice-visuals.md index 2f77811..5b5731a 100644 --- a/docs/design/voice-visuals.md +++ b/docs/design/voice-visuals.md @@ -30,13 +30,14 @@ after the iOS Dynamic Island caption look. Bars are 2.5pt wide capsules, 2.5pt apart, resting at 3pt and reaching about 14pt. The shape grows with the text. -**Waveform + Draft** — Compact's leading live draft with a slim Chart Line -ribbon in the `24 * scale` band and Edge Glow on the silhouette. No five-bar -indicator, particle cloud, or orb. The concert waveform styles stay on -Waveform; this pick is one designed Chart Line. Glow palette colours the -beam, the shaping caption, and the status ghost. Putting the draft on top of -the 64-point waveform was prototyped and removed; this is the other -direction: the draft has its own band. +**Waveform + Draft** — Compact's leading live draft with a `12 * scale` +Chart Line ribbon at the top of the text band, flush under the housing, and +Edge Glow on the silhouette. No five-bar indicator, particle cloud, or orb. +The concert waveform styles stay on Waveform; this pick is one designed +Chart Line. Glow palette colours the beam, the shaping caption, and the +status ghost. Putting the draft on top of the 64-point waveform was +prototyped and removed; this is the other direction: the ribbon shares the +draft's band so the words sit higher. Under Reduce Motion all four are replaced by a quiet level meter in the slim `24 * scale` band, and the draft text always shows. From f4a9111508fecdf02d22a760fa3aa7c6122b5c83 Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Thu, 3 Sep 2026 20:21:18 -0700 Subject: [PATCH 03/11] Show volatile draft immediately and stop tweening it The live HUD was waiting on a 250ms spring for every recognizer update. Volatile guesses now paint as they arrive, lighter than committed words, and the transcriber uses Apple's progressive live preset. --- CONTEXT.md | 2 +- ...rectDictationController+Dependencies.swift | 4 +-- .../Dictation/DirectDictationController.swift | 7 ++-- .../Dictation/HUD/DictationHUDContent.swift | 4 +++ .../HUD/DictationHUDController.swift | 21 ++++++----- .../Dictation/HUD/DictationHUDShellView.swift | 35 ++++++++++++++----- .../Dictation/SpeechRecognitionService.swift | 10 +++--- .../DirectDictationControllerTests.swift | 2 +- TalkifyTests/HUDPlaceholderTests.swift | 10 ++++++ 9 files changed, 67 insertions(+), 28 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index cbcd812..c92cf0b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -111,7 +111,7 @@ _Avoid_: Transcript history, cloud analytics - Releasing the **Dictation Trigger** before 250 milliseconds counts as a quick tap - A quick tap leaves **Direct Dictation** active until the next trigger press - The **Direct Dictation** HUD shows live draft text while speech continues, unless the selected voice visual replaces it; with Reduce Motion the draft text always shows -- The HUD renders volatile recognition results immediately +- The HUD renders volatile recognition results immediately, in a lighter weight than finalized words, so new text can show before it commits - **Direct Dictation** inserts finalized recognition text plus the latest volatile result when recognition ends before finalizing it - The **Direct Dictation** HUD expands from the physical notch on the focused input's display - Settings exposes **Appearance**, **Sounds**, and **Insights** in that order diff --git a/Talkify/Dictation/DirectDictationController+Dependencies.swift b/Talkify/Dictation/DirectDictationController+Dependencies.swift index d60e190..b04bfd5 100644 --- a/Talkify/Dictation/DirectDictationController+Dependencies.swift +++ b/Talkify/Dictation/DirectDictationController+Dependencies.swift @@ -52,7 +52,7 @@ extension DirectDictationController { _ languageTag: String? ) -> Void let showLatched: @MainActor () -> Void - let showLiveText: @MainActor (String) -> Void + let showLiveText: @MainActor (_ committed: String, _ volatile: String) -> Void let showFinalizing: @MainActor () -> Void /// The visible shaping phase: the HUD stays up saying which prompt is /// rewriting the finished words instead of dismissing into silence. @@ -138,7 +138,7 @@ extension DirectDictationController { ) }, showLatched: { hudController.showLatched() }, - showLiveText: { hudController.showLiveText($0) }, + showLiveText: { hudController.showLiveText($0, volatile: $1) }, showFinalizing: { hudController.showFinalizing() }, showShaping: { hudController.showShaping(with: $0) }, showShapingChoice: { hudController.showShapingChoice($0) }, diff --git a/Talkify/Dictation/DirectDictationController.swift b/Talkify/Dictation/DirectDictationController.swift index 6c13395..ecc0322 100644 --- a/Talkify/Dictation/DirectDictationController.swift +++ b/Talkify/Dictation/DirectDictationController.swift @@ -548,7 +548,7 @@ final class DirectDictationController { dependencies.showLatched() case .showLiveText: if let pendingLiveText { - dependencies.showLiveText(pendingLiveText) + dependencies.showLiveText(pendingLiveText, pendingVolatileText) } case .showFinalizing: dependencies.showFinalizing() @@ -576,6 +576,7 @@ final class DirectDictationController { /// The text carried alongside the current `updateReceived` action; the /// machine decides whether it shows, the controller remembers what. private var pendingLiveText: String? + private var pendingVolatileText = "" private func checkAndBegin() { // Held here rather than once recording starts: the first HUD frames are @@ -675,9 +676,11 @@ final class DirectDictationController { let hasVisibleText = !displayText .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty - pendingLiveText = displayText + pendingLiveText = update.finalizedText + pendingVolatileText = update.volatileText send(.updateReceived(hasVisibleText: hasVisibleText)) pendingLiveText = nil + pendingVolatileText = "" } /// Writes the session's entry, after any translation and before the diff --git a/Talkify/Dictation/HUD/DictationHUDContent.swift b/Talkify/Dictation/HUD/DictationHUDContent.swift index e091fb5..1dafe54 100644 --- a/Talkify/Dictation/HUD/DictationHUDContent.swift +++ b/Talkify/Dictation/HUD/DictationHUDContent.swift @@ -7,6 +7,10 @@ import Observation @Observable final class DictationHUDContent { var text = "" + /// The recognizer's current guess, not yet finalized. Drawn lighter so + /// new words can show before they commit (CONTEXT.md: volatile results + /// render immediately). + var volatileText = "" /// Drives the reveal/dismiss animation. var isRevealed = false /// True only while listening — the visuals react to the microphone, so diff --git a/Talkify/Dictation/HUD/DictationHUDController.swift b/Talkify/Dictation/HUD/DictationHUDController.swift index fe29010..a556672 100644 --- a/Talkify/Dictation/HUD/DictationHUDController.swift +++ b/Talkify/Dictation/HUD/DictationHUDController.swift @@ -80,18 +80,18 @@ final class DictationHUDController { // and the transcription keeps running with the status item carrying it. stage.claim(.dictation, on: screen, rendering: settings) startVoiceVisual() - content.text = placeholder + setDraft(placeholder) stage.revealDictation() } func showLatched() { guard isListening else { return } sessionIsLatched = true - content.text = placeholder + setDraft(placeholder) } /// Exposed so the placeholder rules can be asserted without a window. - var textForTesting: String { content.text } + var textForTesting: String { content.text + content.volatileText } /// What the band says before any words arrive. /// @@ -110,12 +110,12 @@ final class DictationHUDController { /// restores whatever the session was saying before. func showModelDownload(_ text: String?) { guard isListening else { return } - content.text = text ?? placeholder + setDraft(text ?? placeholder) } - func showLiveText(_ text: String) { - guard isListening, !text.isEmpty else { return } - content.text = text + func showLiveText(_ committed: String, volatile: String = "") { + guard isListening, !(committed + volatile).isEmpty else { return } + setDraft(committed, volatile: volatile) } /// Speech has stopped but the recognized text has not arrived yet. @@ -149,10 +149,15 @@ final class DictationHUDController { // the caption below it says what is actually happening. A draft the user // really spoke stays, because those are the words being rewritten. if content.text == Self.listeningText || content.text == Self.latchedText { - content.text = "" + setDraft("") } } + private func setDraft(_ committed: String, volatile: String = "") { + content.text = committed + content.volatileText = volatile + } + /// The session's shaping pick by name; nil clears its tag. Cycling arrives /// mid-session, so this only speaks while the dictation session holds the /// shape. diff --git a/Talkify/Dictation/HUD/DictationHUDShellView.swift b/Talkify/Dictation/HUD/DictationHUDShellView.swift index 17c518e..5c5d68b 100644 --- a/Talkify/Dictation/HUD/DictationHUDShellView.swift +++ b/Talkify/Dictation/HUD/DictationHUDShellView.swift @@ -146,9 +146,13 @@ struct DictationHUDShellView: View { // clears the camera, and an overlay so it never changes the fixed // window's size. .overlay(alignment: .topLeading) { languageTag } + // Live draft snaps in: a spring on every volatile update made new + // words wait a quarter-second behind the recognizer. Grow Down + // still animates the island's height, not the glyphs. .animation( - settings.longDraftStyle == .growDown ? .spring(duration: 0.25, bounce: 0) : nil, - value: content.text + settings.longDraftStyle == .growDown + ? .spring(duration: 0.18, bounce: 0) : nil, + value: draftHeightToken ) .animation(.spring(duration: 0.25, bounce: 0), value: visualBandHeight) }, @@ -318,21 +322,36 @@ struct DictationHUDShellView: View { return housing + belowHousing } + /// Coarse height for Grow Down's spring: one step per ~40 characters, so + /// wrapping animates the island without tweening every volatile letter. + private var draftHeightToken: Int { + (content.text.count + content.volatileText.count) / 40 + } + + /// Committed draft in full white, the current guess lighter, so new words + /// show as soon as the recognizer emits them. + private var liveDraft: Text { + Text( + "\(Text(content.text))" + + "\(Text(content.volatileText).foregroundStyle(.white.opacity(0.55)))" + ) + } + /// The Compact draft: the same long-draft semantics, leading-aligned so /// the text hangs off the indicator instead of floating centered. @ViewBuilder private var compactDraftText: some View { switch settings.longDraftStyle { case .tailOnly: - Text(content.text) + liveDraft .lineLimit(1) .truncationMode(.head) case .growDown: - Text(content.text) + liveDraft .lineLimit(4) .multilineTextAlignment(.leading) case .shrinkToFit: - Text(content.text) + liveDraft .lineLimit(1) .truncationMode(.head) .minimumScaleFactor(0.55) @@ -347,15 +366,15 @@ struct DictationHUDShellView: View { private var draftText: some View { switch settings.longDraftStyle { case .tailOnly: - Text(content.text) + liveDraft .lineLimit(1) .truncationMode(.head) case .growDown: - Text(content.text) + liveDraft .lineLimit(4) .multilineTextAlignment(.center) case .shrinkToFit: - Text(content.text) + liveDraft .lineLimit(1) .truncationMode(.head) .minimumScaleFactor(0.55) diff --git a/Talkify/Dictation/SpeechRecognitionService.swift b/Talkify/Dictation/SpeechRecognitionService.swift index ad36380..158d710 100644 --- a/Talkify/Dictation/SpeechRecognitionService.swift +++ b/Talkify/Dictation/SpeechRecognitionService.swift @@ -328,14 +328,12 @@ actor SpeechRecognitionService { try await reserve(locale: locale) + // progressiveTranscription is Apple's live-audio preset: volatile + // guesses plus fastResults, so the draft streams while speaking + // instead of appearing only at pauses. let transcriber = SpeechTranscriber( locale: locale, - transcriptionOptions: [], - // fastResults biases the transcriber towards responsiveness so - // the live draft streams while the user is still speaking, - // instead of appearing only at pauses. - reportingOptions: [.volatileResults, .fastResults], - attributeOptions: [] + preset: .progressiveTranscription ) // Only a missing model produces a request, so this is the one branch that diff --git a/TalkifyTests/DirectDictationControllerTests.swift b/TalkifyTests/DirectDictationControllerTests.swift index b183568..5f6d56e 100644 --- a/TalkifyTests/DirectDictationControllerTests.swift +++ b/TalkifyTests/DirectDictationControllerTests.swift @@ -142,7 +142,7 @@ struct DirectDictationControllerTests { recorder.listeningTags.append(languageTag) }, showLatched: { recorder.events.append("showLatched") }, - showLiveText: { _ in recorder.events.append("showLiveText") }, + showLiveText: { _, _ in recorder.events.append("showLiveText") }, showFinalizing: { recorder.events.append("showFinalizing") }, showShaping: { name in recorder.events.append("showShaping") diff --git a/TalkifyTests/HUDPlaceholderTests.swift b/TalkifyTests/HUDPlaceholderTests.swift index 780c27e..7527c18 100644 --- a/TalkifyTests/HUDPlaceholderTests.swift +++ b/TalkifyTests/HUDPlaceholderTests.swift @@ -104,4 +104,14 @@ struct HUDPlaceholderTests { hud.showLiveText("hello there") #expect(hud.textForTesting == "hello there") } + + @Test func aVolatileGuessShowsBeforeItCommits() { + let store = AppSettings.previewStore() + store.voiceVisual = .waveDraft + let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store) + + hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store)) + hud.showLiveText("hello ", volatile: "ther") + #expect(hud.textForTesting == "hello ther") + } } From 3041de456b4868cf293531d0bf0d20d6e0d6289e Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Thu, 3 Sep 2026 20:42:41 -0700 Subject: [PATCH 04/11] Draw the live draft as attributed text, not a Text dump String-interpolating Text views printed their debug description into the HUD. Committed and volatile words now share one AttributedString. --- Talkify/Dictation/HUD/DictationHUDShellView.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Talkify/Dictation/HUD/DictationHUDShellView.swift b/Talkify/Dictation/HUD/DictationHUDShellView.swift index 5c5d68b..2fbc810 100644 --- a/Talkify/Dictation/HUD/DictationHUDShellView.swift +++ b/Talkify/Dictation/HUD/DictationHUDShellView.swift @@ -331,10 +331,10 @@ struct DictationHUDShellView: View { /// Committed draft in full white, the current guess lighter, so new words /// show as soon as the recognizer emits them. private var liveDraft: Text { - Text( - "\(Text(content.text))" - + "\(Text(content.volatileText).foregroundStyle(.white.opacity(0.55)))" - ) + let committed = AttributedString(content.text) + var guess = AttributedString(content.volatileText) + guess.foregroundColor = Color.white.opacity(0.55) + return Text(committed + guess) } /// The Compact draft: the same long-draft semantics, leading-aligned so From 87aa780eac23dfe97e62b90af2d9b26db40241de Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Thu, 3 Sep 2026 21:02:39 -0700 Subject: [PATCH 05/11] Keep the ribbon with the voice visual and time the draft correctly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A status message could keep the previous guess glued on, and Waveform + Draft's Slide park used a size 24 points too short. Volatile words also use regular weight so they read as the uncommitted half. The ribbon had the same problem the volatile text did: it was drawn whenever Waveform + Draft was picked, not only while a voice visual was up. A status message renders with the session's settings and no visual, over a level history that only a session start clears, so an error arrived above a frozen squiggle of the words that had just failed to insert, on a band 24 points taller than any other visual's message. The gate is a static now, so a test can hold it there. Grow Down sprang on a character count, one step per 40, but wrapping follows rendered width — roughly 90 characters to a line — so the spring fired where nothing had moved and the island snapped where it actually grew, and a volatile guess crossing a boundary back and forth fired it again for nothing. It springs on the draft itself, and a transaction holds the glyphs out of that animation, which is what the count was there to do. Pausing the edge glow for a dead microphone skipped the beam rather than pausing it: the sweep reads elapsed wall-clock time and sweepStart only moved on a session start, so a recovered microphone resumed somewhere else on the silhouette. It absorbs the quiet interval now. The concert Chart Line had the older half of the same bug — its timeline never paused at all, so the dead-mic amber kept scrolling when it is meant to be motionless. Both Chart Lines read one silver from HUDVisualTokens. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EpgExpqh8faqvAnRiBZHb6 --- Talkify/CoreHUD/HUDStage.swift | 2 + .../Dictation/HUD/DictationHUDShellView.swift | 54 +++++++++++++++---- .../HUD/Visuals/ChartLineWaveView.swift | 34 +++++------- .../HUD/Visuals/HUDCompactChartLineView.swift | 17 +----- .../HUD/Visuals/HUDEdgeGlowView.swift | 15 ++++++ .../HUD/Visuals/HUDVisualTokens.swift | 16 ++++++ TalkifyTests/HUDMetricsTests.swift | 14 +++++ TalkifyTests/HUDNotchGeometryTests.swift | 3 +- TalkifyTests/HUDPlaceholderTests.swift | 11 ++++ docs/design/voice-visuals.md | 2 +- 10 files changed, 119 insertions(+), 49 deletions(-) diff --git a/Talkify/CoreHUD/HUDStage.swift b/Talkify/CoreHUD/HUDStage.swift index 3fea830..8813193 100644 --- a/Talkify/CoreHUD/HUDStage.swift +++ b/Talkify/CoreHUD/HUDStage.swift @@ -147,6 +147,7 @@ final class HUDStage { // through the retract. dictationContent.isDismissing = false dictationContent.text = "" + dictationContent.volatileText = "" dropContent.mode = .none dropContent.heldIcon = nil dropContent.transcript = nil @@ -170,6 +171,7 @@ final class HUDStage { claim(.message, on: screen) dictationContent.showsVoiceVisual = false dictationContent.text = text + dictationContent.volatileText = "" revealDictation() messageDismissTask?.cancel() diff --git a/Talkify/Dictation/HUD/DictationHUDShellView.swift b/Talkify/Dictation/HUD/DictationHUDShellView.swift index 2fbc810..1fe1e93 100644 --- a/Talkify/Dictation/HUD/DictationHUDShellView.swift +++ b/Talkify/Dictation/HUD/DictationHUDShellView.swift @@ -47,7 +47,11 @@ struct DictationHUDShellView: View { HUDNotchGeometry.contentSize( for: screen, metrics: metrics, - visualBandHeight: visualBandHeight, + // Waveform + Draft folds the 24-point ribbon into the text band, so + // the visual band is empty; the extra height still has to be in the + // declared size or Slide parks 4 points short of hiding the island. + visualBandHeight: visualBandHeight + + (showsWaveDraftRibbon ? metrics.visualBandHeight : 0), includesTextBand: showsTextBand, shapingBandHeight: showsShapingLabel ? metrics.shapingBandHeight : 0 ) @@ -146,13 +150,16 @@ struct DictationHUDShellView: View { // clears the camera, and an overlay so it never changes the fixed // window's size. .overlay(alignment: .topLeading) { languageTag } - // Live draft snaps in: a spring on every volatile update made new - // words wait a quarter-second behind the recognizer. Grow Down - // still animates the island's height, not the glyphs. + // Grow Down springs the island's height, and only that. The glyphs + // are held still by the transaction on the draft itself, so new + // words land the moment the recognizer emits them. Keyed on the + // draft rather than a character count, because wrapping follows + // rendered width: a count-based step fires springs where nothing + // moved and snaps where the island actually grew. .animation( settings.longDraftStyle == .growDown ? .spring(duration: 0.18, bounce: 0) : nil, - value: draftHeightToken + value: draftValue ) .animation(.spring(duration: 0.25, bounce: 0), value: visualBandHeight) }, @@ -245,7 +252,25 @@ struct DictationHUDShellView: View { } private var showsWaveDraftRibbon: Bool { - settings.voiceVisual == .waveDraft && !reduceMotion + Self.showsRibbon( + visual: settings.voiceVisual, + keepsVisualLayout: keepsVisualLayout, + reduceMotion: reduceMotion + ) + } + + /// Gated on the listening layout for the same reason `visualBandHeight` is, + /// and static so the gate can be asserted without a window: a status message + /// renders with the session's settings but no voice visual, and + /// `levelHistory` still holds the last session's audio, so an ungated ribbon + /// draws a frozen squiggle of the words that just failed to insert — above a + /// band 24 points taller than any other visual's message. + static func showsRibbon( + visual: HUDVoiceVisualStyle, + keepsVisualLayout: Bool, + reduceMotion: Bool + ) -> Bool { + keepsVisualLayout && visual == .waveDraft && !reduceMotion } /// The room the language tag needs on each side. @@ -322,19 +347,26 @@ struct DictationHUDShellView: View { return housing + belowHousing } - /// Coarse height for Grow Down's spring: one step per ~40 characters, so - /// wrapping animates the island without tweening every volatile letter. - private var draftHeightToken: Int { - (content.text.count + content.volatileText.count) / 40 + /// Everything on screen, so the height spring fires exactly when the draft + /// changes — including on a volatile guess, which is what moves the wrap. + private var draftValue: String { + content.text + content.volatileText } /// Committed draft in full white, the current guess lighter, so new words /// show as soon as the recognizer emits them. - private var liveDraft: Text { + /// + /// The transaction is what keeps them prompt: words appear, they do not + /// fade in. Grow Down's spring lives on the bands and still animates the + /// island's height around this — it is only the glyphs that are held out + /// of it, which is what made new words trail the recognizer. + private var liveDraft: some View { let committed = AttributedString(content.text) var guess = AttributedString(content.volatileText) + guess.font = .system(size: 15 * metrics.scale, weight: .regular) guess.foregroundColor = Color.white.opacity(0.55) return Text(committed + guess) + .transaction { $0.animation = nil } } /// The Compact draft: the same long-draft semantics, leading-aligned so diff --git a/Talkify/Dictation/HUD/Visuals/ChartLineWaveView.swift b/Talkify/Dictation/HUD/Visuals/ChartLineWaveView.swift index 09c6ae4..bdc543b 100644 --- a/Talkify/Dictation/HUD/Visuals/ChartLineWaveView.swift +++ b/Talkify/Dictation/HUD/Visuals/ChartLineWaveView.swift @@ -17,18 +17,26 @@ struct ChartLineWaveView: View { /// Measured time between level ticks, smoothed; scroll speed follows it. @State private var tickInterval: Double = 0.022 + private var live: Bool { + content.showsVoiceVisual && content.isAudioAlive + } + var body: some View { - TimelineView(.animation) { context in + // Paused for a dead microphone as well as a stopped session: the amber + // state is a motionless line (HUDVisualTokens), and an unpaused timeline + // kept scrolling it, leaving silence and a dead mic separated by colour + // alone (CONTEXT.md). + TimelineView(.animation(paused: !live)) { context in let progress = min(1, max(0, context.date.timeIntervalSince(lastTick) / tickInterval)) let level = content.audioLevel - let shape = SmoothLineShape(samples: samples, scrollProgress: progress) + let shape = SmoothLineShape(samples: samples, scrollProgress: live ? progress : 0) if content.isAudioAlive { ZStack { // Ambient under-glow: a wide soft pool of light beneath // the line, breathing with the voice. Ellipse() - .fill(Self.silver) + .fill(HUDVisualTokens.chartLineSilver) .frame(height: 26) .padding(.horizontal, 60) .blur(radius: 22) @@ -38,7 +46,7 @@ struct ChartLineWaveView: View { // rest line, soft and fading downward. shape .stroke( - Self.silver, + HUDVisualTokens.chartLineSilver, style: StrokeStyle(lineWidth: 2.2, lineCap: .round, lineJoin: .round) ) .scaleEffect(y: -1) @@ -56,7 +64,7 @@ struct ChartLineWaveView: View { // bloom reads as light on glass rather than color. shape .stroke( - Self.silver, + HUDVisualTokens.chartLineSilver, style: StrokeStyle( lineWidth: 4 + 8 * level, lineCap: .round, @@ -68,7 +76,7 @@ struct ChartLineWaveView: View { // Tight bloom hugging the core. shape .stroke( - Self.silver, + HUDVisualTokens.chartLineSilver, style: StrokeStyle( lineWidth: 2.5 + 3 * level, lineCap: .round, @@ -117,20 +125,6 @@ struct ChartLineWaveView: View { } } - /// Metallic silver: white body cooling into a faint blue-gray at the - /// ends — the edge glow's palette, no saturated hues. - private static let silver = LinearGradient( - colors: [ - Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), - .white, - Color(red: 0.82, green: 0.86, blue: 0.95), - .white, - Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), - ], - startPoint: .leading, - endPoint: .trailing - ) - /// A soft bright band ping-ponging across the strip; masking the extra /// specular stroke with it makes the light travel along the line. private func specularBand(at date: Date) -> some View { diff --git a/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift b/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift index 4b4033b..b9cc19f 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift @@ -27,7 +27,7 @@ struct HUDCompactChartLineView: View { ZStack { shape .stroke( - Self.silver, + HUDVisualTokens.chartLineSilver, style: StrokeStyle( lineWidth: (1.4 + 2 * level) * scale, lineCap: .round, @@ -65,19 +65,4 @@ struct HUDCompactChartLineView: View { .allowsHitTesting(false) .accessibilityHidden(true) } - - /// Same metallic silver as the concert Chart Line, so the ribbon is - /// recognisably that treatment rather than a second product. Palette - /// colour stays on the edge beam. - private static let silver = LinearGradient( - colors: [ - Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), - .white, - Color(red: 0.82, green: 0.86, blue: 0.95), - .white, - Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), - ], - startPoint: .leading, - endPoint: .trailing - ) } diff --git a/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift b/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift index 3ff2939..a5fdae3 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift @@ -43,6 +43,14 @@ struct HUDEdgeGlowView: View { /// directly under the housing. @State private var sweepStart = Date() + /// When the microphone went quiet, while the timeline is paused for it. + /// The sweep is driven by elapsed wall-clock time, so without this the + /// beam resumes having advanced through the whole dead interval and its + /// bright region jumps to somewhere else on the silhouette. Pushing + /// `sweepStart` forward by the paused duration is what makes the pause a + /// pause rather than a skip. + @State private var quietSince: Date? + /// Mirrors `content.showsVoiceVisual` for the ramp's keyframe trigger. /// The trigger must *change* for the keyframes to play; a view mounted /// while listening is already true (the Settings preview switching back @@ -116,6 +124,13 @@ struct HUDEdgeGlowView: View { .accessibilityHidden(true) .onChange(of: content.sessionEpoch) { sweepStart = .now + quietSince = nil + } + .onChange(of: content.isAudioAlive, initial: true) { _, alive in + if alive, let quietSince { + sweepStart = sweepStart.addingTimeInterval(Date().timeIntervalSince(quietSince)) + } + quietSince = alive ? nil : Date() } .onChange(of: content.showsVoiceVisual, initial: true) { _, listening in ramped = listening diff --git a/Talkify/Dictation/HUD/Visuals/HUDVisualTokens.swift b/Talkify/Dictation/HUD/Visuals/HUDVisualTokens.swift index ecfd0f4..18afde9 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDVisualTokens.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDVisualTokens.swift @@ -7,4 +7,20 @@ enum HUDVisualTokens { /// visuals keep their own dimmer orange variants, tuned per visual /// during feel tests. static let deadMicAmber = Color(red: 1.0, green: 0.6, blue: 0.16) + + /// The Chart Line treatment's metallic silver: white body cooling into a + /// faint blue-gray at the ends — the edge glow's palette, no saturated + /// hues. Shared so the concert band and the Waveform + Draft ribbon are + /// recognisably one treatment rather than two that drifted apart. + static let chartLineSilver = LinearGradient( + colors: [ + Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), + .white, + Color(red: 0.82, green: 0.86, blue: 0.95), + .white, + Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), + ], + startPoint: .leading, + endPoint: .trailing + ) } diff --git a/TalkifyTests/HUDMetricsTests.swift b/TalkifyTests/HUDMetricsTests.swift index 88111cf..eecc63b 100644 --- a/TalkifyTests/HUDMetricsTests.swift +++ b/TalkifyTests/HUDMetricsTests.swift @@ -110,6 +110,20 @@ struct HUDShellMetricsTests { ).scale } + /// A status message renders with the session's settings and no voice + /// visual, over a level history the last session filled. An ungated ribbon + /// drew that stale audio, frozen, above every error the user ever sees. + @Test func theRibbonLeavesWithTheVoiceVisual() { + #expect(DictationHUDShellView.showsRibbon( + visual: .waveDraft, keepsVisualLayout: true, reduceMotion: false)) + #expect(!DictationHUDShellView.showsRibbon( + visual: .waveDraft, keepsVisualLayout: false, reduceMotion: false)) + #expect(!DictationHUDShellView.showsRibbon( + visual: .waveDraft, keepsVisualLayout: true, reduceMotion: true)) + #expect(!DictationHUDShellView.showsRibbon( + visual: .compact, keepsVisualLayout: true, reduceMotion: false)) + } + @Test func aDraftLayoutIsHeldAtTheReadableFloorAndOthersAreNot() { #expect(scale(picked: 0.2, visual: .compact) == HUDMetrics.minimumReadableScale) #expect(scale(picked: 0.2, visual: .waveDraft) == HUDMetrics.minimumReadableScale) diff --git a/TalkifyTests/HUDNotchGeometryTests.swift b/TalkifyTests/HUDNotchGeometryTests.swift index e8e637f..9e80ce2 100644 --- a/TalkifyTests/HUDNotchGeometryTests.swift +++ b/TalkifyTests/HUDNotchGeometryTests.swift @@ -150,7 +150,7 @@ struct HUDNotchGeometryTests { let content = HUDNotchGeometry.contentSize( for: notched, metrics: .standard, - visualBandHeight: 0, + visualBandHeight: HUDMetrics.standard.visualBandHeight, includesTextBand: true, shapingBandHeight: HUDMetrics.standard.shapingBandHeight ) @@ -159,6 +159,7 @@ struct HUDNotchGeometryTests { + HUDMetrics.standard.maxTextBandHeight + HUDMetrics.standard.shapingBandHeight #expect(content.height == 32 + + HUDMetrics.standard.visualBandHeight + HUDMetrics.standard.textBandHeight + HUDMetrics.standard.shapingBandHeight) #expect(growDown <= window.height - HUDNotchGeometry.shadowPadding) diff --git a/TalkifyTests/HUDPlaceholderTests.swift b/TalkifyTests/HUDPlaceholderTests.swift index 7527c18..24cdd78 100644 --- a/TalkifyTests/HUDPlaceholderTests.swift +++ b/TalkifyTests/HUDPlaceholderTests.swift @@ -114,4 +114,15 @@ struct HUDPlaceholderTests { hud.showLiveText("hello ", volatile: "ther") #expect(hud.textForTesting == "hello ther") } + + @Test func aStatusMessageDoesNotKeepThePreviousGuess() { + let store = AppSettings.previewStore() + store.voiceVisual = .waveDraft + let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store) + + hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store)) + hud.showLiveText("hello ", volatile: "ther") + hud.showMessage("Couldn't insert text") + #expect(hud.textForTesting == "Couldn't insert text") + } } diff --git a/docs/design/voice-visuals.md b/docs/design/voice-visuals.md index 5b5731a..d9acbf9 100644 --- a/docs/design/voice-visuals.md +++ b/docs/design/voice-visuals.md @@ -31,7 +31,7 @@ after the iOS Dynamic Island caption look. Bars are 2.5pt wide capsules, the text. **Waveform + Draft** — Compact's leading live draft with a `12 * scale` -Chart Line ribbon at the top of the text band, flush under the housing, and +Chart Line ribbon at the top of the text band, just under the housing, and Edge Glow on the silhouette. No five-bar indicator, particle cloud, or orb. The concert waveform styles stay on Waveform; this pick is one designed Chart Line. Glow palette colours the beam, the shaping caption, and the From dfb90e00bb8dd0a2e697de8890b74a54814d4773 Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Thu, 3 Sep 2026 23:04:51 -0700 Subject: [PATCH 06/11] Keep the ribbon through shaping, not through a status message hide() pins isDismissing so the shape does not resize while it slides away, and claim() cancels that order-out, so a later showMessage inherited the retract pin. Waveform + Draft then drew the last session's audio above a band 24 points taller than any other visual's message. showMessage now clears the retract pin and the shaping caption; the ribbon stays for listening, retract, and shaping, and leaves with the status line. Grow Down is keyed on a coarse character step again. Wrapping by rendered width is real, but keying the spring on every volatile string put new words behind a layout transaction; glyphs still opt out of that spring. A dead-mic Chart Line keeps the paused timeline's progress instead of snapping a sample slot. Edge Glow subtracts an open quiet interval from elapsed time so the first unpaused frame does not jump. The 12-point ribbon uses most of its strip and square-root samples, because the concert 0.75-point rest lift flattened quiet speech into a shaking straight line. --- Talkify/CoreHUD/HUDStage.swift | 5 +++ .../Dictation/HUD/DictationHUDShellView.swift | 45 ++++++++----------- .../HUD/Visuals/ChartLineWaveView.swift | 11 ++--- .../HUD/Visuals/HUDCompactChartLineView.swift | 16 +++++-- .../HUD/Visuals/HUDEdgeGlowView.swift | 25 ++++++----- .../Dictation/HUD/Visuals/WaveShapes.swift | 13 +++++- TalkifyTests/HUDMetricsTests.swift | 25 +++++++---- TalkifyTests/HUDPlaceholderTests.swift | 35 +++++++++++++++ TalkifyTests/SmoothLineShapeTests.swift | 38 ++++++++++++++++ 9 files changed, 160 insertions(+), 53 deletions(-) create mode 100644 TalkifyTests/SmoothLineShapeTests.swift diff --git a/Talkify/CoreHUD/HUDStage.swift b/Talkify/CoreHUD/HUDStage.swift index 8813193..5352d0f 100644 --- a/Talkify/CoreHUD/HUDStage.swift +++ b/Talkify/CoreHUD/HUDStage.swift @@ -169,6 +169,11 @@ final class HUDStage { func showMessage(_ text: String, on displayID: CGDirectDisplayID? = nil) { guard let screen = screen(preferring: displayID) else { return } claim(.message, on: screen) + // hide() pins these for the retract, and claim() cancels that order-out. + // A message is a new occupant and must not inherit them. + dictationContent.isDismissing = false + dictationContent.shapingName = nil + dictationContent.shapingChoiceLabel = nil dictationContent.showsVoiceVisual = false dictationContent.text = text dictationContent.volatileText = "" diff --git a/Talkify/Dictation/HUD/DictationHUDShellView.swift b/Talkify/Dictation/HUD/DictationHUDShellView.swift index 1fe1e93..b1a09f1 100644 --- a/Talkify/Dictation/HUD/DictationHUDShellView.swift +++ b/Talkify/Dictation/HUD/DictationHUDShellView.swift @@ -150,16 +150,12 @@ struct DictationHUDShellView: View { // clears the camera, and an overlay so it never changes the fixed // window's size. .overlay(alignment: .topLeading) { languageTag } - // Grow Down springs the island's height, and only that. The glyphs - // are held still by the transaction on the draft itself, so new - // words land the moment the recognizer emits them. Keyed on the - // draft rather than a character count, because wrapping follows - // rendered width: a count-based step fires springs where nothing - // moved and snaps where the island actually grew. + // Grow Down springs the island's height. Glyphs opt out so new + // words land immediately; the token is coarse so a wrap is one spring. .animation( settings.longDraftStyle == .growDown ? .spring(duration: 0.18, bounce: 0) : nil, - value: draftValue + value: draftHeightToken ) .animation(.spring(duration: 0.25, bounce: 0), value: visualBandHeight) }, @@ -254,23 +250,24 @@ struct DictationHUDShellView: View { private var showsWaveDraftRibbon: Bool { Self.showsRibbon( visual: settings.voiceVisual, - keepsVisualLayout: keepsVisualLayout, + listening: content.showsVoiceVisual, + dismissing: content.isDismissing, + shaping: content.shapingName != nil, reduceMotion: reduceMotion ) } - /// Gated on the listening layout for the same reason `visualBandHeight` is, - /// and static so the gate can be asserted without a window: a status message - /// renders with the session's settings but no voice visual, and - /// `levelHistory` still holds the last session's audio, so an ungated ribbon - /// draws a frozen squiggle of the words that just failed to insert — above a - /// band 24 points taller than any other visual's message. + /// Compact keeps its indicator through shaping and retracts; the ribbon + /// does the same, but a status message is a new occupant and must not + /// inherit the last session's audio or the extra 24 points of height. static func showsRibbon( visual: HUDVoiceVisualStyle, - keepsVisualLayout: Bool, + listening: Bool, + dismissing: Bool, + shaping: Bool, reduceMotion: Bool ) -> Bool { - keepsVisualLayout && visual == .waveDraft && !reduceMotion + visual == .waveDraft && !reduceMotion && (listening || dismissing || shaping) } /// The room the language tag needs on each side. @@ -347,19 +344,15 @@ struct DictationHUDShellView: View { return housing + belowHousing } - /// Everything on screen, so the height spring fires exactly when the draft - /// changes — including on a volatile guess, which is what moves the wrap. - private var draftValue: String { - content.text + content.volatileText + /// Coarse height for Grow Down's spring: one step per ~40 characters, so + /// wrapping animates the island without tweening every volatile letter. + private var draftHeightToken: Int { + (content.text.count + content.volatileText.count) / 40 } /// Committed draft in full white, the current guess lighter, so new words - /// show as soon as the recognizer emits them. - /// - /// The transaction is what keeps them prompt: words appear, they do not - /// fade in. Grow Down's spring lives on the bands and still animates the - /// island's height around this — it is only the glyphs that are held out - /// of it, which is what made new words trail the recognizer. + /// show as soon as the recognizer emits them. Glyphs opt out of Grow Down's + /// height spring so they do not fade in with it. private var liveDraft: some View { let committed = AttributedString(content.text) var guess = AttributedString(content.volatileText) diff --git a/Talkify/Dictation/HUD/Visuals/ChartLineWaveView.swift b/Talkify/Dictation/HUD/Visuals/ChartLineWaveView.swift index bdc543b..a13d3e8 100644 --- a/Talkify/Dictation/HUD/Visuals/ChartLineWaveView.swift +++ b/Talkify/Dictation/HUD/Visuals/ChartLineWaveView.swift @@ -22,14 +22,15 @@ struct ChartLineWaveView: View { } var body: some View { - // Paused for a dead microphone as well as a stopped session: the amber - // state is a motionless line (HUDVisualTokens), and an unpaused timeline - // kept scrolling it, leaving silence and a dead mic separated by colour - // alone (CONTEXT.md). + // Paused when the session is over or the microphone is dead, so the + // amber line is motionless (CONTEXT.md: dead ≠ silent). TimelineView(.animation(paused: !live)) { context in let progress = min(1, max(0, context.date.timeIntervalSince(lastTick) / tickInterval)) let level = content.audioLevel - let shape = SmoothLineShape(samples: samples, scrollProgress: live ? progress : 0) + let shape = SmoothLineShape( + samples: samples, + scrollProgress: (live || lastTick != .distantPast) ? progress : 0 + ) if content.isAudioAlive { ZStack { diff --git a/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift b/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift index b9cc19f..daa09b3 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift @@ -20,7 +20,11 @@ struct HUDCompactChartLineView: View { TimelineView(.animation(paused: !live)) { context in let progress = min(1, max(0, context.date.timeIntervalSince(lastTick) / tickInterval)) let level = content.audioLevel - let shape = SmoothLineShape(samples: samples, scrollProgress: live ? progress : 0) + let shape = SmoothLineShape( + samples: samples, + scrollProgress: (live || lastTick != .distantPast) ? progress : 0, + fill: 0.8 + ) let hairline = max(1, 1.2 * scale) if content.isAudioAlive { @@ -52,17 +56,23 @@ struct HUDCompactChartLineView: View { } .padding(.horizontal, 10 * scale) .clipped() - .onAppear { samples = content.levelHistory } + .onAppear { samples = Self.ribbonSamples(content.levelHistory) } .onChange(of: content.levelHistory) { _, new in let now = Date() let gap = now.timeIntervalSince(lastTick) if gap < 0.1 { tickInterval = tickInterval * 0.8 + gap * 0.2 } - samples = new + samples = Self.ribbonSamples(new) lastTick = now } .allowsHitTesting(false) .accessibilityHidden(true) } + + /// Square-root so quiet speech still fills the 12-point strip. Linear + /// samples sat under the concert rest lift and read as a straight line. + private static func ribbonSamples(_ samples: [Float]) -> [Float] { + samples.map { $0.squareRoot() } + } } diff --git a/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift b/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift index a5fdae3..1fee89d 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDEdgeGlowView.swift @@ -43,12 +43,9 @@ struct HUDEdgeGlowView: View { /// directly under the housing. @State private var sweepStart = Date() - /// When the microphone went quiet, while the timeline is paused for it. - /// The sweep is driven by elapsed wall-clock time, so without this the - /// beam resumes having advanced through the whole dead interval and its - /// bright region jumps to somewhere else on the silhouette. Pushing - /// `sweepStart` forward by the paused duration is what makes the pause a - /// pause rather than a skip. + /// When the microphone went quiet. Elapsed time subtracts this so the + /// first unpaused frame does not jump; onChange then folds it into + /// `sweepStart`. @State private var quietSince: Date? /// Mirrors `content.showsVoiceVisual` for the ramp's keyframe trigger. @@ -76,7 +73,7 @@ struct HUDEdgeGlowView: View { let lineWidth = Self.lineWidth * Double(metrics.scale) * (1 + level) let blurRadius = Self.blurRadius * Double(metrics.scale) * (1 + 0.5 * level) let origin = Self.sweepOrigin( - at: context.date.timeIntervalSince(sweepStart), + at: sweepElapsed(at: context.date), in: size, cornerRadius: metrics.bottomCornerRadius, topFilletRadius: topFilletRadius, @@ -123,20 +120,28 @@ struct HUDEdgeGlowView: View { .allowsHitTesting(false) .accessibilityHidden(true) .onChange(of: content.sessionEpoch) { - sweepStart = .now + sweepStart = Date.now quietSince = nil } .onChange(of: content.isAudioAlive, initial: true) { _, alive in + let now = Date.now if alive, let quietSince { - sweepStart = sweepStart.addingTimeInterval(Date().timeIntervalSince(quietSince)) + sweepStart = sweepStart.addingTimeInterval(now.timeIntervalSince(quietSince)) } - quietSince = alive ? nil : Date() + quietSince = alive ? nil : now } .onChange(of: content.showsVoiceVisual, initial: true) { _, listening in ramped = listening } } + /// Wall-clock elapsed, minus any open dead-mic pause, so the first + /// unpaused frame is already at the freeze point. + private func sweepElapsed(at date: Date) -> TimeInterval { + date.timeIntervalSince(sweepStart) + - (quietSince.map { date.timeIntervalSince($0) } ?? 0) + } + /// The eased ping-pong position along the silhouette at time `t` (left /// tip → bottom → right tip → back), phase-shifted so t = 0 lands at /// bottom-center. Shared with the particle cloud so the motes chase the diff --git a/Talkify/Dictation/HUD/Visuals/WaveShapes.swift b/Talkify/Dictation/HUD/Visuals/WaveShapes.swift index 211b21e..f680c6f 100644 --- a/Talkify/Dictation/HUD/Visuals/WaveShapes.swift +++ b/Talkify/Dictation/HUD/Visuals/WaveShapes.swift @@ -5,16 +5,27 @@ import SwiftUI struct SmoothLineShape: Shape { let samples: [Float] let scrollProgress: Double + /// Fraction of the rect height a full-scale sample occupies, measured + /// up from the rest line. Concert Chart Line keeps 0.5 so the reflection + /// can sit in the lower half; the ribbon uses more so a 12-point strip + /// is not half empty. + var fill: CGFloat = 0.5 nonisolated func path(in rect: CGRect) -> Path { guard samples.count > 2 else { return Path() } let step = rect.width / CGFloat(samples.count - 2) let offset = CGFloat(scrollProgress) * step + let span = rect.height * fill + let restY = rect.minY + span + // Concert's 0.75pt floor at 64pt. Scaled with the span so a 12-point + // ribbon (and the 40% HUD-size floor) does not clamp quiet speech + // to a straight line. + let lift = min(0.75, span * 0.025) let points = samples.enumerated().map { index, sample in CGPoint( x: rect.minX + CGFloat(index) * step - offset, - y: rect.midY - max(0.75, CGFloat(sample) * rect.height / 2) + y: restY - max(lift, CGFloat(sample) * span) ) } diff --git a/TalkifyTests/HUDMetricsTests.swift b/TalkifyTests/HUDMetricsTests.swift index eecc63b..237e552 100644 --- a/TalkifyTests/HUDMetricsTests.swift +++ b/TalkifyTests/HUDMetricsTests.swift @@ -110,18 +110,27 @@ struct HUDShellMetricsTests { ).scale } - /// A status message renders with the session's settings and no voice - /// visual, over a level history the last session filled. An ungated ribbon - /// drew that stale audio, frozen, above every error the user ever sees. - @Test func theRibbonLeavesWithTheVoiceVisual() { + /// Listening, retracting, and shaping keep the ribbon; a status message + /// does not. Compact never has one. + @Test func theRibbonFollowsOccupancy() { #expect(DictationHUDShellView.showsRibbon( - visual: .waveDraft, keepsVisualLayout: true, reduceMotion: false)) + visual: .waveDraft, listening: true, dismissing: false, shaping: false, + reduceMotion: false)) + #expect(DictationHUDShellView.showsRibbon( + visual: .waveDraft, listening: false, dismissing: true, shaping: false, + reduceMotion: false)) + #expect(DictationHUDShellView.showsRibbon( + visual: .waveDraft, listening: false, dismissing: false, shaping: true, + reduceMotion: false)) #expect(!DictationHUDShellView.showsRibbon( - visual: .waveDraft, keepsVisualLayout: false, reduceMotion: false)) + visual: .waveDraft, listening: false, dismissing: false, shaping: false, + reduceMotion: false)) #expect(!DictationHUDShellView.showsRibbon( - visual: .waveDraft, keepsVisualLayout: true, reduceMotion: true)) + visual: .waveDraft, listening: true, dismissing: false, shaping: false, + reduceMotion: true)) #expect(!DictationHUDShellView.showsRibbon( - visual: .compact, keepsVisualLayout: true, reduceMotion: false)) + visual: .compact, listening: true, dismissing: false, shaping: false, + reduceMotion: false)) } @Test func aDraftLayoutIsHeldAtTheReadableFloorAndOthersAreNot() { diff --git a/TalkifyTests/HUDPlaceholderTests.swift b/TalkifyTests/HUDPlaceholderTests.swift index 24cdd78..3542bbd 100644 --- a/TalkifyTests/HUDPlaceholderTests.swift +++ b/TalkifyTests/HUDPlaceholderTests.swift @@ -20,6 +20,16 @@ struct HUDPlaceholderTests { store.sessionSettings } + private func ribbon(_ stage: HUDStage, visual: HUDVoiceVisualStyle) -> Bool { + DictationHUDShellView.showsRibbon( + visual: visual, + listening: stage.dictationContent.showsVoiceVisual, + dismissing: stage.dictationContent.isDismissing, + shaping: stage.dictationContent.shapingName != nil, + reduceMotion: false + ) + } + @Test(arguments: [HUDVoiceVisualStyle.compact, .waveDraft]) func aDraftVisualOpensWithNoText(visual: HUDVoiceVisualStyle) { let store = AppSettings.previewStore() @@ -125,4 +135,29 @@ struct HUDPlaceholderTests { hud.showMessage("Couldn't insert text") #expect(hud.textForTesting == "Couldn't insert text") } + + /// hide() pins the layout for the retract, then an insert failure claims + /// the shape for a message. That message is a new occupant: the ribbon + /// stays through shaping and the retract, and leaves with the status line. + @Test func aStatusMessageDoesNotKeepTheRibbon() { + let store = AppSettings.previewStore() + store.voiceVisual = .waveDraft + let stage = HUDStage(settings: store) + let hud = DictationHUDController(stage: stage, settings: store) + + hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store)) + hud.showLiveText("the words it is rewriting") + hud.showShaping(with: "Tighten grammar") + #expect(ribbon(stage, visual: .waveDraft)) + + hud.hide() + #expect(stage.dictationContent.isDismissing) + #expect(ribbon(stage, visual: .waveDraft)) + + hud.showMessage("Couldn't insert text") + #expect(!stage.dictationContent.isDismissing) + #expect(stage.dictationContent.shapingName == nil) + #expect(!ribbon(stage, visual: .waveDraft)) + #expect(hud.textForTesting == "Couldn't insert text") + } } diff --git a/TalkifyTests/SmoothLineShapeTests.swift b/TalkifyTests/SmoothLineShapeTests.swift new file mode 100644 index 0000000..72fcbc3 --- /dev/null +++ b/TalkifyTests/SmoothLineShapeTests.swift @@ -0,0 +1,38 @@ +import CoreGraphics +import SwiftUI +import Testing + +@testable import Talkify + +/// The concert rest lift is 0.75pt in a 64-point band. Applied unchanged +/// to the 12-point ribbon (and the 40% HUD-size floor) it is larger than +/// quiet speech, so the line never moves. +@Suite("Smooth line shape") +struct SmoothLineShapeTests { + @Test func quietSpeechMovesInTheRibbon() { + let samples: [Float] = [0, 0.2, 0.4, 0.25, 0] + let ribbon = SmoothLineShape( + samples: samples.map { $0.squareRoot() }, + scrollProgress: 0, + fill: 0.8 + ) + .path(in: CGRect(x: 0, y: 0, width: 200, height: 12)) + + let concertMapping = SmoothLineShape( + samples: samples, + scrollProgress: 0 + ) + .path(in: CGRect(x: 0, y: 0, width: 200, height: 12)) + + #expect(ribbon.boundingRect.height > 2) + #expect(ribbon.boundingRect.height > concertMapping.boundingRect.height) + } + + @Test func theConcertBandStillRestsNearTheMidline() { + let path = SmoothLineShape(samples: [0, 1, 0], scrollProgress: 0) + .path(in: CGRect(x: 0, y: 0, width: 100, height: 64)) + #expect(path.boundingRect.maxY > 30) + #expect(path.boundingRect.maxY < 33) + #expect(path.boundingRect.minY < 16) + } +} From 7d2a1cb9cf69f6ab1e6f27408698eb4493dbace4 Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Fri, 4 Sep 2026 08:35:47 -0700 Subject: [PATCH 07/11] Show the last eight words and let the ribbon turn off Waveform + Draft keeps one 18-point line of recent tokens instead of wrapping the full draft, and Settings can hide the Chart Line so the live draft sits in the glow on its own. Insertion still uses the full transcript. --- CONTEXT.md | 6 +- Talkify/App/AppSettings.swift | 12 +++ .../Dictation/HUD/DictationHUDShellView.swift | 61 +++++++++--- .../Dictation/HUD/HUDRecentDraftText.swift | 97 +++++++++++++++++++ .../HUD/Visuals/HUDVoiceVisualStyle.swift | 8 +- .../Sections/AppearanceSettingsView.swift | 45 +++++++-- TalkifyTests/AppSettingsTests.swift | 7 ++ TalkifyTests/HUDMetricsTests.swift | 6 ++ TalkifyTests/HUDRecentDraftWindowTests.swift | 80 +++++++++++++++ TalkifyTests/SettingsWindowTests.swift | 9 ++ docs/design/voice-visuals.md | 8 +- 11 files changed, 309 insertions(+), 30 deletions(-) create mode 100644 Talkify/Dictation/HUD/HUDRecentDraftText.swift create mode 100644 TalkifyTests/HUDRecentDraftWindowTests.swift diff --git a/CONTEXT.md b/CONTEXT.md index c92cf0b..5a48df4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -150,14 +150,14 @@ _Avoid_: Transcript history, cloud analytics - Settings changes apply to the live app and persist immediately; Settings has no Save step - The Appearance preview uses the same preferences and HUD surface as Direct Dictation - Settings changes update the Appearance preview immediately, while an active Direct Dictation session keeps the choices captured at session start -- Dictation session settings include the voice visual, waveform style, glow palette, glow center, reveal style, long-draft behavior, HUD size, sound set, sound enabled state, sound volume, insertion destination, the transcription history choice, the shaping choice, and whether the session lowers other audio +- Dictation session settings include the voice visual, waveform style, glow palette, glow center, Waveform + Draft's Chart Line ribbon, reveal style, long-draft behavior, HUD size, sound set, sound enabled state, sound volume, insertion destination, the transcription history choice, the shaping choice, and whether the session lowers other audio - **Direct Dictation** can lower other audio while it listens and put it back when the session ends, cancels or fails; it is off by default because the control it moves is system-wide - macOS has no per-application ducking, so lowering other audio moves the default output device's own volume and quiets Talkify's session sounds along with everything else - A lowered volume is restored only while it is still the value Talkify set: a volume the user changed mid-session is theirs, the same rule the clipboard restore follows - The device that was lowered is the one restored, not whichever output is default when the session ends: the default can change mid-session, and the volume that needs putting back belongs to the device that was quieted - An output with no settable volume does not duck, and neither does one already at silence - A third voice visual, Compact, shows a small five-bar voice indicator beside the leading-aligned live draft inside the shape, after the iOS Dynamic Island caption look; the shape grows with the draft -- A fourth voice visual, Waveform + Draft, keeps Compact's leading live draft and wraps the shape in Edge Glow, with a slim Chart Line ribbon at the top of the text band instead of the five-bar indicator; it has no particle cloud and no orb +- A fourth voice visual, Waveform + Draft, keeps a leading live draft of the last eight word tokens at 18-point type on one line and wraps the shape in Edge Glow. A slim Chart Line ribbon can sit at the top of the text band; it is on by default and can be turned off, leaving the draft and the glow. It has no particle cloud and no orb. New words paint immediately; a departing window fades left. Insertion still uses the full transcript. - Compact and Waveform + Draft show the live draft while listening; Waveform and Edge Glow replace the draft text entirely - An In the Shape live-draft placement for Waveform and Edge Glow was prototyped and removed; putting words on the concert waveform stays rejected. Waveform + Draft is the chosen live-draft direction: the draft has its own band under a slim ribbon, in one shape - A detached child pill under the island (the uni-pills goo bubble) was prototyped as a third placement and rejected by feel: a second detached surface does not fit the single-shape design @@ -424,5 +424,5 @@ _Avoid_: Transcript history, cloud analytics - Settings navigation uses a fixed Talkify blue accent; selected Glow palettes do not recolor the Settings shell - "subtitles" was used for text displayed during a Google Meet call — resolved: this is **Live Captions**. -- HUD behavior for long drafts — resolved: the downward-growing panel won the feel test against tail-only truncation and shrink-to-fit. The HUD wraps long drafts and grows downward, capped at four lines; the other two variants remain selectable for the Settings picker. +- HUD behavior for long drafts — resolved: the downward-growing panel won the feel test against tail-only truncation and shrink-to-fit. The HUD wraps long drafts and grows downward, capped at four lines; the other two variants remain selectable for the Settings picker. Waveform + Draft is the exception: while its ribbon is up it shows the last eight word tokens on one 18-point line instead of those behaviors, because wrapping fights a larger type next to the ribbon. Reduce Motion restores the plain band, where the global pick still applies. - The voice-reactive visual — resolved: Waveform and Edge Glow ship and replace draft text while listening; Compact shows the live draft with a five-bar indicator; Waveform + Draft is the fourth pick, a designed combo of slim Chart Line, live draft, and Edge Glow chrome with no glow center. The waveform (default: the Chart Line conveyor with metallic silver treatment, plus seven alternate styles) fills the visual band; Waveform + Draft does not offer those styles. The edge glow is a three-part animation — a palette-gradient beam whose bright region blooms in from under the housing on session start, sweeps the shape's open silhouette while listening, follows the voice in brightness and stroke thickness (the feel-test pick over reach, particle count, particle size, and syllable bursts), and drains back when the session ends, a one-shot ripple wave rolling across the housing at session start, and a particle cloud in the palette's colors chasing the beam's sweeping origin. Waveform + Draft keeps the beam and the ripple and drops the center. The palette (Spectrum, Silver, Aurora, Sunset, Ocean, Mono) is a Settings pick coloring beam and particles together, and the shaping caption. diff --git a/Talkify/App/AppSettings.swift b/Talkify/App/AppSettings.swift index c00dc6d..659dc04 100644 --- a/Talkify/App/AppSettings.swift +++ b/Talkify/App/AppSettings.swift @@ -23,6 +23,7 @@ final class AppSettings { static let longDraftStyle = "hudLongDraftStyle" static let glowPalette = "hudGlowPalette" static let glowCenter = "hudGlowCenter" + static let waveDraftShowsRibbon = "hudWaveDraftShowsRibbon" static let hudScale = "hudScale" static let readAloudVoice = "readAloudVoice" static let readAloudTranslates = "readAloudTranslates" @@ -161,6 +162,13 @@ final class AppSettings { didSet { defaults.set(glowCenter.rawValue, forKey: Keys.glowCenter) } } + /// Waveform + Draft's slim Chart Line under the housing. Off keeps the + /// live draft and the glow. Default on, so an upgrade does not strip + /// the ribbon from a pick that already includes it. + var waveDraftShowsRibbon: Bool { + didSet { defaults.set(waveDraftShowsRibbon, forKey: Keys.waveDraftShowsRibbon) } + } + /// How large the HUD shape is, as a fraction of the standard size. /// `HUDMetrics` clamps it to its supported range; a stored value outside /// that range comes back clamped rather than refused. @@ -316,6 +324,8 @@ final class AppSettings { longDraftStyle = Self.stored(in: defaults, key: Keys.longDraftStyle) ?? .growDown glowPalette = Self.stored(in: defaults, key: Keys.glowPalette) ?? .spectrum glowCenter = Self.stored(in: defaults, key: Keys.glowCenter) ?? .particles + waveDraftShowsRibbon = + defaults.object(forKey: Keys.waveDraftShowsRibbon) as? Bool ?? true // `double(forKey:)` reads a missing key as zero, which would start every // existing user at the smallest HUD, so absence is checked directly. hudScale = defaults.object(forKey: Keys.hudScale) as? Double @@ -467,6 +477,7 @@ struct DictationSessionSettings: Equatable { let longDraftStyle: HUDLongDraftStyle let glowPalette: HUDGlowPalette let glowCenter: HUDGlowCenterStyle + let waveDraftShowsRibbon: Bool let hudMetrics: HUDMetrics /// The colour a Drop Transcription wears — on the HUD's target and card, and @@ -500,6 +511,7 @@ struct DictationSessionSettings: Equatable { longDraftStyle = settings.longDraftStyle glowPalette = settings.glowPalette glowCenter = settings.glowCenter + waveDraftShowsRibbon = settings.waveDraftShowsRibbon hudMetrics = HUDMetrics(scale: CGFloat(settings.hudScale)) } } diff --git a/Talkify/Dictation/HUD/DictationHUDShellView.swift b/Talkify/Dictation/HUD/DictationHUDShellView.swift index b1a09f1..f5d410f 100644 --- a/Talkify/Dictation/HUD/DictationHUDShellView.swift +++ b/Talkify/Dictation/HUD/DictationHUDShellView.swift @@ -153,7 +153,7 @@ struct DictationHUDShellView: View { // Grow Down springs the island's height. Glyphs opt out so new // words land immediately; the token is coarse so a wrap is one spring. .animation( - settings.longDraftStyle == .growDown + settings.longDraftStyle == .growDown && !showsRecentDraft ? .spring(duration: 0.18, bounce: 0) : nil, value: draftHeightToken ) @@ -206,18 +206,23 @@ struct DictationHUDShellView: View { /// Draft text lives in the band below the housing. Compact puts its /// voice indicator on the leading side, Dynamic Island-style, with the /// draft leading-aligned beside it. Waveform + Draft puts a thin Chart - /// Line flush under the housing and keeps that alignment, so the words - /// get the strip a dedicated ribbon band would have occupied. The other - /// visuals center the draft. + /// Line flush under the housing and a recent-word line under that, so + /// the words get the strip a dedicated ribbon band would have occupied. + /// The other visuals center the draft. @ViewBuilder private var textBand: some View { Group { - if showsWaveDraftRibbon { + if showsRecentDraft { VStack(alignment: .leading, spacing: 4 * metrics.scale) { - HUDCompactChartLineView(content: content, scale: metrics.scale) - .frame(height: metrics.ribbonBandHeight) - compactDraftText - .frame(maxWidth: .infinity, alignment: .leading) + if showsWaveDraftRibbon { + HUDCompactChartLineView(content: content, scale: metrics.scale) + .frame(height: metrics.ribbonBandHeight) + } + HUDRecentDraftText( + committed: content.text, + volatile: content.volatileText, + scale: metrics.scale + ) } } else if showsLeadingDraft { HStack(alignment: .top, spacing: 10 * metrics.scale) { @@ -237,7 +242,7 @@ struct DictationHUDShellView: View { // The tag sits in the inset rather than in the flow, and the inset grows // on both sides, so centered drafts stay centered and nothing overlaps. .padding(.horizontal, tagInset * metrics.scale) - .padding(.top, showsWaveDraftRibbon ? 4 * metrics.scale : 9 * metrics.scale) + .padding(.top, showsRecentDraft ? 4 * metrics.scale : 9 * metrics.scale) .padding(.bottom, 9 * metrics.scale) // Waveform + Draft folds the old 24-point ribbon band into this // minimum so the island does not shrink and the extra height is text. @@ -247,16 +252,39 @@ struct DictationHUDShellView: View { ) } + private var showsRecentDraft: Bool { + Self.showsRecentDraft( + visual: settings.voiceVisual, + listening: content.showsVoiceVisual, + dismissing: content.isDismissing, + shaping: content.shapingName != nil, + reduceMotion: reduceMotion + ) + } + private var showsWaveDraftRibbon: Bool { Self.showsRibbon( visual: settings.voiceVisual, listening: content.showsVoiceVisual, dismissing: content.isDismissing, shaping: content.shapingName != nil, - reduceMotion: reduceMotion + reduceMotion: reduceMotion, + ribbonEnabled: settings.waveDraftShowsRibbon ) } + /// Waveform + Draft's recent-word line. Same occupancy as the ribbon: + /// listening, retracting, and shaping keep it; a status message does not. + static func showsRecentDraft( + visual: HUDVoiceVisualStyle, + listening: Bool, + dismissing: Bool, + shaping: Bool, + reduceMotion: Bool + ) -> Bool { + visual == .waveDraft && !reduceMotion && (listening || dismissing || shaping) + } + /// Compact keeps its indicator through shaping and retracts; the ribbon /// does the same, but a status message is a new occupant and must not /// inherit the last session's audio or the extra 24 points of height. @@ -265,9 +293,16 @@ struct DictationHUDShellView: View { listening: Bool, dismissing: Bool, shaping: Bool, - reduceMotion: Bool + reduceMotion: Bool, + ribbonEnabled: Bool = true ) -> Bool { - visual == .waveDraft && !reduceMotion && (listening || dismissing || shaping) + ribbonEnabled && showsRecentDraft( + visual: visual, + listening: listening, + dismissing: dismissing, + shaping: shaping, + reduceMotion: reduceMotion + ) } /// The room the language tag needs on each side. diff --git a/Talkify/Dictation/HUD/HUDRecentDraftText.swift b/Talkify/Dictation/HUD/HUDRecentDraftText.swift new file mode 100644 index 0000000..d4eac21 --- /dev/null +++ b/Talkify/Dictation/HUD/HUDRecentDraftText.swift @@ -0,0 +1,97 @@ +import NaturalLanguage +import SwiftUI + +/// The visible suffix of a live draft: the last `wordLimit` word tokens of +/// the joined committed and volatile strings. Display only — insertion still +/// uses the full transcript. +struct HUDRecentDraftWindow: Equatable { + static let wordLimit = 8 + + let committed: String + let volatile: String + /// Character offset of the visible suffix in the joined draft. + /// Advances when an old word is evicted; a volatile rewrite can move it back. + let evictionOffset: Int + + init(committed: String, volatile: String, limit: Int = Self.wordLimit) { + let joined = committed + volatile + guard !joined.isEmpty else { + self.committed = "" + self.volatile = "" + self.evictionOffset = 0 + return + } + + let tokenizer = NLTokenizer(unit: .word) + tokenizer.string = joined + var tokens: [Range] = [] + tokenizer.enumerateTokens(in: joined.startIndex..= boundary { + self.committed = "" + self.volatile = String(joined[start...]) + } else { + self.committed = String(joined[start.. lastEvictionOffset + ZStack(alignment: .leading) { + line(window) + .id(window.evictionOffset) + .transition(.asymmetric( + insertion: .identity, + removal: .offset(x: -8 * scale).combined(with: .opacity) + )) + } + .frame(maxWidth: .infinity, alignment: .leading) + .clipped() + .animation(movesOut ? .easeOut(duration: 0.13) : nil, value: window.evictionOffset) + .onChange(of: window.evictionOffset) { _, new in + lastEvictionOffset = new + } + } + + private func line(_ window: HUDRecentDraftWindow) -> some View { + let size = 18 * scale + var committed = AttributedString(window.committed) + committed.font = .system(size: size, weight: .medium) + committed.foregroundColor = .white + var guess = AttributedString(window.volatile) + guess.font = .system(size: size, weight: .regular) + guess.foregroundColor = Color.white.opacity(0.55) + return Text(committed + guess) + .lineLimit(1) + .truncationMode(.head) + } +} diff --git a/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift b/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift index bc0b135..a10d7c3 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift @@ -5,10 +5,10 @@ enum HUDVoiceVisualStyle: String, CaseIterable { /// A Metal shader waveform that fills the HUD's visual band. case waveform = "Waveform" - /// Slim Chart Line under the housing, Compact's live draft below it, and - /// Edge Glow on the silhouette. No five-bar indicator, particle cloud, or - /// orb — those sit on the words. The concert waveform styles stay on - /// Waveform; this composition is one designed Chart Line. + /// Compact's live draft and Edge Glow on the silhouette, with an optional + /// slim Chart Line under the housing. No five-bar indicator, particle + /// cloud, or orb — those sit on the words. The concert waveform styles + /// stay on Waveform; this composition is one designed Chart Line. case waveDraft = "Waveform + Draft" /// An origin glow hugging the shape's edge: it blooms out of the notch /// housing on session start, breathes with the voice, and drains back diff --git a/Talkify/Settings/Sections/AppearanceSettingsView.swift b/Talkify/Settings/Sections/AppearanceSettingsView.swift index 6895121..be8da37 100644 --- a/Talkify/Settings/Sections/AppearanceSettingsView.swift +++ b/Talkify/Settings/Sections/AppearanceSettingsView.swift @@ -25,6 +25,20 @@ struct AppearanceSettingsView: View { visual == .glow } + static func showsWaveDraftRibbon(for visual: HUDVoiceVisualStyle) -> Bool { + visual == .waveDraft + } + + /// Waveform + Draft uses a recent-word line instead of the global long + /// draft behaviors. Reduce Motion restores the plain band, where the + /// existing pick still applies. + static func showsLongDraftBehavior( + for visual: HUDVoiceVisualStyle, + reduceMotion: Bool + ) -> Bool { + !(visual == .waveDraft && !reduceMotion) + } + var body: some View { VStack(alignment: .leading, spacing: 18) { SettingsPreviewCard(settings: settings) @@ -67,6 +81,18 @@ struct AppearanceSettingsView: View { selection: $settings.glowCenter ) } + + if Self.showsWaveDraftRibbon(for: settings.voiceVisual) { + SettingsRow( + title: "Chart Line ribbon", + description: "The slim waveform under the housing. Off leaves " + + "the live draft and the glow." + ) { + Toggle("Chart Line ribbon", isOn: $settings.waveDraftShowsRibbon) + .labelsHidden() + .toggleStyle(.switch) + } + } } SettingsCard(title: "Motion and layout") { @@ -99,13 +125,18 @@ struct AppearanceSettingsView: View { selection: $settings.revealStyle ) - SettingsPickerRow( - title: "Long draft behavior", - description: "How the HUD handles longer dictated text", - options: HUDLongDraftStyle.allCases, - optionLabel: { $0.rawValue }, - selection: $settings.longDraftStyle - ) + if Self.showsLongDraftBehavior( + for: settings.voiceVisual, + reduceMotion: reduceMotion + ) { + SettingsPickerRow( + title: "Long draft behavior", + description: "How the HUD handles longer dictated text", + options: HUDLongDraftStyle.allCases, + optionLabel: { $0.rawValue }, + selection: $settings.longDraftStyle + ) + } } } } diff --git a/TalkifyTests/AppSettingsTests.swift b/TalkifyTests/AppSettingsTests.swift index 7912db8..edcc309 100644 --- a/TalkifyTests/AppSettingsTests.swift +++ b/TalkifyTests/AppSettingsTests.swift @@ -20,6 +20,7 @@ struct AppSettingsTests { #expect(settings.waveformStyle == .chartLine) #expect(settings.revealStyle == .slide) #expect(settings.longDraftStyle == .growDown) + #expect(settings.waveDraftShowsRibbon) // Existing users must not be resized by an upgrade. #expect(settings.hudScale == Double(HUDMetrics.maximumScale)) #expect(settings.readAloudVoiceID.isEmpty) @@ -37,6 +38,7 @@ struct AppSettingsTests { settings.waveformStyle = .dots settings.revealStyle = .bloom settings.longDraftStyle = .shrinkToFit + settings.waveDraftShowsRibbon = false settings.hudScale = 0.75 settings.readAloudVoiceID = "com.apple.voice.premium.en-US.Zoe" let f5Binding = KeyBinding( @@ -58,6 +60,7 @@ struct AppSettingsTests { #expect(reloaded.waveformStyle == .dots) #expect(reloaded.revealStyle == .bloom) #expect(reloaded.longDraftStyle == .shrinkToFit) + #expect(!reloaded.waveDraftShowsRibbon) #expect(reloaded.hudScale == 0.75) #expect(reloaded.readAloudVoiceID == "com.apple.voice.premium.en-US.Zoe") #expect(reloaded.dictationTriggerBinding == rightCommandTrigger) @@ -85,6 +88,8 @@ struct AppSettingsTests { #expect(defaults.string(forKey: "hudWaveformStyle") == "Silver") #expect(defaults.string(forKey: "hudRevealStyle") == "Drift") #expect(defaults.string(forKey: "hudLongDraftStyle") == "Tail Only") + settings.waveDraftShowsRibbon = false + #expect(defaults.object(forKey: "hudWaveDraftShowsRibbon") as? Bool == false) #expect(defaults.string(forKey: "readAloudVoice") == "com.apple.voice.enhanced.en-GB.Jamie") } @@ -328,6 +333,7 @@ struct AppSettingsTests { settings.waveformStyle = .dots settings.revealStyle = .bloom settings.longDraftStyle = .tailOnly + settings.waveDraftShowsRibbon = false settings.glowPalette = .aurora settings.glowCenter = .siriOrb settings.hudScale = 0.6 @@ -337,6 +343,7 @@ struct AppSettingsTests { #expect(snapshot.waveformStyle == .chartLine) #expect(snapshot.revealStyle == .slide) #expect(snapshot.longDraftStyle == .growDown) + #expect(snapshot.waveDraftShowsRibbon) #expect(snapshot.glowPalette == .spectrum) #expect(snapshot.glowCenter == .particles) #expect(snapshot.hudMetrics == .standard) diff --git a/TalkifyTests/HUDMetricsTests.swift b/TalkifyTests/HUDMetricsTests.swift index 237e552..9d38cd8 100644 --- a/TalkifyTests/HUDMetricsTests.swift +++ b/TalkifyTests/HUDMetricsTests.swift @@ -131,6 +131,12 @@ struct HUDShellMetricsTests { #expect(!DictationHUDShellView.showsRibbon( visual: .compact, listening: true, dismissing: false, shaping: false, reduceMotion: false)) + #expect(!DictationHUDShellView.showsRibbon( + visual: .waveDraft, listening: true, dismissing: false, shaping: false, + reduceMotion: false, ribbonEnabled: false)) + #expect(DictationHUDShellView.showsRecentDraft( + visual: .waveDraft, listening: true, dismissing: false, shaping: false, + reduceMotion: false)) } @Test func aDraftLayoutIsHeldAtTheReadableFloorAndOthersAreNot() { diff --git a/TalkifyTests/HUDRecentDraftWindowTests.swift b/TalkifyTests/HUDRecentDraftWindowTests.swift new file mode 100644 index 0000000..ff583b3 --- /dev/null +++ b/TalkifyTests/HUDRecentDraftWindowTests.swift @@ -0,0 +1,80 @@ +import Testing + +@testable import Talkify + +@Suite("Recent draft window") +struct HUDRecentDraftWindowTests { + @Test func fewerThanEightTokensStayWhole() { + let window = HUDRecentDraftWindow( + committed: "one two three", + volatile: " four" + ) + #expect(window.committed == "one two three") + #expect(window.volatile == " four") + #expect(window.evictionOffset == 0) + } + + @Test func exactlyEightTokensStayWhole() { + let window = HUDRecentDraftWindow( + committed: "one two three four five six seven eight", + volatile: "" + ) + #expect(window.committed == "one two three four five six seven eight") + #expect(window.volatile.isEmpty) + #expect(window.evictionOffset == 0) + } + + @Test func theNinthTokenEvictsTheFirst() { + let window = HUDRecentDraftWindow( + committed: "one two three four five six seven eight nine", + volatile: "" + ) + #expect(window.committed == "two three four five six seven eight nine") + #expect(window.volatile.isEmpty) + #expect(window.evictionOffset > 0) + } + + @Test func aPartialVolatileTokenCountsAsOneWord() { + let window = HUDRecentDraftWindow( + committed: "one two three four five six seven ", + volatile: "eigh" + ) + #expect(window.committed == "one two three four five six seven ") + #expect(window.volatile == "eigh") + #expect(window.evictionOffset == 0) + } + + @Test func aVolatileTokenCanCrossTheEvictionBoundary() { + let window = HUDRecentDraftWindow( + committed: "one two three four five six seven eight ", + volatile: "nine" + ) + #expect(window.committed == "two three four five six seven eight ") + #expect(window.volatile == "nine") + #expect(window.evictionOffset > 0) + } + + @Test func punctuationAndSpacesStayInsideTheSuffix() { + let window = HUDRecentDraftWindow( + committed: "Hello, world. This is a longer line, yes it is.", + volatile: "" + ) + #expect(window.committed.hasPrefix("This")) + #expect(window.committed.contains("line,")) + #expect(!window.committed.contains("Hello")) + } + + @Test func aStringWithNoWordTokensIsUnchanged() { + let window = HUDRecentDraftWindow(committed: "— —", volatile: " !!!") + #expect(window.committed == "— —") + #expect(window.volatile == " !!!") + #expect(window.evictionOffset == 0) + } + + @Test func emptyInputIsEmpty() { + let window = HUDRecentDraftWindow(committed: "", volatile: "") + #expect(window.committed.isEmpty) + #expect(window.volatile.isEmpty) + #expect(window.evictionOffset == 0) + } +} diff --git a/TalkifyTests/SettingsWindowTests.swift b/TalkifyTests/SettingsWindowTests.swift index e2b85d2..8143ee6 100644 --- a/TalkifyTests/SettingsWindowTests.swift +++ b/TalkifyTests/SettingsWindowTests.swift @@ -63,6 +63,15 @@ struct SettingsWindowTests { #expect(AppearanceSettingsView.showsGlowCenter(for: .glow)) #expect(!AppearanceSettingsView.showsGlowCenter(for: .waveDraft)) #expect(!AppearanceSettingsView.showsGlowCenter(for: .waveform)) + #expect(!AppearanceSettingsView.showsLongDraftBehavior( + for: .waveDraft, reduceMotion: false)) + #expect(AppearanceSettingsView.showsLongDraftBehavior( + for: .waveDraft, reduceMotion: true)) + #expect(AppearanceSettingsView.showsLongDraftBehavior( + for: .compact, reduceMotion: false)) + #expect(AppearanceSettingsView.showsWaveDraftRibbon(for: .waveDraft)) + #expect(!AppearanceSettingsView.showsWaveDraftRibbon(for: .waveform)) + #expect(!AppearanceSettingsView.showsWaveDraftRibbon(for: .glow)) } @Test func releaseMetadataExcludesUnlicensedOptions() { diff --git a/docs/design/voice-visuals.md b/docs/design/voice-visuals.md index d9acbf9..9aa70d5 100644 --- a/docs/design/voice-visuals.md +++ b/docs/design/voice-visuals.md @@ -30,9 +30,11 @@ after the iOS Dynamic Island caption look. Bars are 2.5pt wide capsules, 2.5pt apart, resting at 3pt and reaching about 14pt. The shape grows with the text. -**Waveform + Draft** — Compact's leading live draft with a `12 * scale` -Chart Line ribbon at the top of the text band, just under the housing, and -Edge Glow on the silhouette. No five-bar indicator, particle cloud, or orb. +**Waveform + Draft** — Compact's leading live draft, but only the last +eight word tokens on one `18 * scale` line, with Edge Glow on the +silhouette. A `12 * scale` Chart Line ribbon can sit at the top of the +text band, just under the housing; it is on by default and can be turned +off. No five-bar indicator, particle cloud, or orb. The concert waveform styles stay on Waveform; this pick is one designed Chart Line. Glow palette colours the beam, the shaping caption, and the status ghost. Putting the draft on top of the 64-point waveform was From 8c33349c11c67c8460449449ec21da1c1d23e9a8 Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Fri, 4 Sep 2026 11:06:49 -0700 Subject: [PATCH 08/11] Center the last eight words and clip overflow at the oldest Waveform + Draft now centers the recent-word line on the housing-plus-stage island, keeps per-word identity so only new and departing tokens slide, and clips the oldest words when the line is wider than the shape instead of ellipsizing each one. Finalizations that merge or rewrite a token snap in place. --- CONTEXT.md | 6 +- Talkify/CoreHUD/HUDMetrics.swift | 5 + .../Dictation/HUD/DictationHUDShellView.swift | 163 ++++++++++-------- .../Dictation/HUD/HUDRecentDraftText.swift | 158 +++++++++++++---- .../HUD/Visuals/HUDVoiceVisualStyle.swift | 9 +- TalkifyTests/HUDMetricsTests.swift | 8 + TalkifyTests/HUDRecentDraftWindowTests.swift | 115 ++++++++++++ TalkifyTests/HUDRenderTests.swift | 50 ++++++ docs/design/hud-anatomy.md | 44 +++-- docs/design/voice-visuals.md | 14 +- 10 files changed, 445 insertions(+), 127 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 5a48df4..10e599e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -111,7 +111,7 @@ _Avoid_: Transcript history, cloud analytics - Releasing the **Dictation Trigger** before 250 milliseconds counts as a quick tap - A quick tap leaves **Direct Dictation** active until the next trigger press - The **Direct Dictation** HUD shows live draft text while speech continues, unless the selected voice visual replaces it; with Reduce Motion the draft text always shows -- The HUD renders volatile recognition results immediately, in a lighter weight than finalized words, so new text can show before it commits +- The HUD renders volatile recognition results immediately, so new text can show before it commits. Compact and the Reduce Motion band draw them lighter than finalized words; Waveform + Draft paints both the same, because that line is a glance, not a proofread - **Direct Dictation** inserts finalized recognition text plus the latest volatile result when recognition ends before finalizing it - The **Direct Dictation** HUD expands from the physical notch on the focused input's display - Settings exposes **Appearance**, **Sounds**, and **Insights** in that order @@ -157,7 +157,7 @@ _Avoid_: Transcript history, cloud analytics - The device that was lowered is the one restored, not whichever output is default when the session ends: the default can change mid-session, and the volume that needs putting back belongs to the device that was quieted - An output with no settable volume does not duck, and neither does one already at silence - A third voice visual, Compact, shows a small five-bar voice indicator beside the leading-aligned live draft inside the shape, after the iOS Dynamic Island caption look; the shape grows with the draft -- A fourth voice visual, Waveform + Draft, keeps a leading live draft of the last eight word tokens at 18-point type on one line and wraps the shape in Edge Glow. A slim Chart Line ribbon can sit at the top of the text band; it is on by default and can be turned off, leaving the draft and the glow. It has no particle cloud and no orb. New words paint immediately; a departing window fades left. Insertion still uses the full transcript. +- A fourth voice visual, Waveform + Draft, keeps a live draft of the last eight word tokens at 24-point type, centered in the island (housing plus a 40-point stage), and wraps the shape in Edge Glow. A slim Chart Line ribbon can sit under the housing; it is on by default and can be turned off without shrinking the box. It has no particle cloud and no orb. Each visible word keeps a stable identity: a new token slides in from the right, a departing token slides off to the left, and a finalization rewrites the same token in place. A line that does not fit clips the oldest words rather than ellipsizing each one. Insertion still uses the full transcript. - Compact and Waveform + Draft show the live draft while listening; Waveform and Edge Glow replace the draft text entirely - An In the Shape live-draft placement for Waveform and Edge Glow was prototyped and removed; putting words on the concert waveform stays rejected. Waveform + Draft is the chosen live-draft direction: the draft has its own band under a slim ribbon, in one shape - A detached child pill under the island (the uni-pills goo bubble) was prototyped as a third placement and rejected by feel: a second detached surface does not fit the single-shape design @@ -424,5 +424,5 @@ _Avoid_: Transcript history, cloud analytics - Settings navigation uses a fixed Talkify blue accent; selected Glow palettes do not recolor the Settings shell - "subtitles" was used for text displayed during a Google Meet call — resolved: this is **Live Captions**. -- HUD behavior for long drafts — resolved: the downward-growing panel won the feel test against tail-only truncation and shrink-to-fit. The HUD wraps long drafts and grows downward, capped at four lines; the other two variants remain selectable for the Settings picker. Waveform + Draft is the exception: while its ribbon is up it shows the last eight word tokens on one 18-point line instead of those behaviors, because wrapping fights a larger type next to the ribbon. Reduce Motion restores the plain band, where the global pick still applies. +- HUD behavior for long drafts — resolved: the downward-growing panel won the feel test against tail-only truncation and shrink-to-fit. The HUD wraps long drafts and grows downward, capped at four lines; the other two variants remain selectable for the Settings picker. Waveform + Draft is the exception: it shows the last eight word tokens on one 24-point centered line instead of those behaviors, because wrapping fights a larger type in the stage. Reduce Motion restores the plain band, where the global pick still applies. - The voice-reactive visual — resolved: Waveform and Edge Glow ship and replace draft text while listening; Compact shows the live draft with a five-bar indicator; Waveform + Draft is the fourth pick, a designed combo of slim Chart Line, live draft, and Edge Glow chrome with no glow center. The waveform (default: the Chart Line conveyor with metallic silver treatment, plus seven alternate styles) fills the visual band; Waveform + Draft does not offer those styles. The edge glow is a three-part animation — a palette-gradient beam whose bright region blooms in from under the housing on session start, sweeps the shape's open silhouette while listening, follows the voice in brightness and stroke thickness (the feel-test pick over reach, particle count, particle size, and syllable bursts), and drains back when the session ends, a one-shot ripple wave rolling across the housing at session start, and a particle cloud in the palette's colors chasing the beam's sweeping origin. Waveform + Draft keeps the beam and the ripple and drops the center. The palette (Spectrum, Silver, Aurora, Sunset, Ocean, Mono) is a Settings pick coloring beam and particles together, and the shaping caption. diff --git a/Talkify/CoreHUD/HUDMetrics.swift b/Talkify/CoreHUD/HUDMetrics.swift index bf9704b..9868e57 100644 --- a/Talkify/CoreHUD/HUDMetrics.swift +++ b/Talkify/CoreHUD/HUDMetrics.swift @@ -68,6 +68,11 @@ struct HUDMetrics: Equatable { /// text band rather than as a band of its own, so the words sit higher. var ribbonBandHeight: CGFloat { 12 * scale } + /// Waveform + Draft hanging band: 24-point type plus 8 points above + /// and below. Tighter than the 36+24 stage that left empty air in the + /// pill; still tall enough that the line does not kiss the stroke. + var waveDraftStageHeight: CGFloat { 40 * scale } + /// Height of the waveform band. The waveform variant replaces the draft /// text entirely, so it gets room to breathe. var waveBandHeight: CGFloat { 64 * scale } diff --git a/Talkify/Dictation/HUD/DictationHUDShellView.swift b/Talkify/Dictation/HUD/DictationHUDShellView.swift index f5d410f..7ffc56b 100644 --- a/Talkify/Dictation/HUD/DictationHUDShellView.swift +++ b/Talkify/Dictation/HUD/DictationHUDShellView.swift @@ -47,11 +47,12 @@ struct DictationHUDShellView: View { HUDNotchGeometry.contentSize( for: screen, metrics: metrics, - // Waveform + Draft folds the 24-point ribbon into the text band, so - // the visual band is empty; the extra height still has to be in the - // declared size or Slide parks 4 points short of hiding the island. + // Waveform + Draft has no visual band; its hanging stage is 40 + // points, 4 more than the ordinary text band. That extra has to be + // in the declared size or Slide parks short of hiding the island. visualBandHeight: visualBandHeight - + (showsWaveDraftRibbon ? metrics.visualBandHeight : 0), + + (showsRecentDraft + ? metrics.waveDraftStageHeight - metrics.textBandHeight : 0), includesTextBand: showsTextBand, shapingBandHeight: showsShapingLabel ? metrics.shapingBandHeight : 0 ) @@ -110,10 +111,9 @@ struct DictationHUDShellView: View { /// Whether the text band is Compact's leading-aligned draft. Not gated on /// the listening state — swapping the band's structure at finalize reads /// as a glitch mid retract, so the layout stays and the indicator settles - /// instead. Waveform + Draft uses the same alignment without the five-bar - /// equalizer, because its Chart Line is already the voice. + /// instead. Waveform + Draft has its own centered stage. private var showsLeadingDraft: Bool { - settings.voiceVisual.showsDraftWhileListening && !reduceMotion + settings.voiceVisual == .compact && !reduceMotion } private var showsCompactIndicator: Bool { @@ -148,8 +148,10 @@ struct DictationHUDShellView: View { // Glow hide the band for the whole listening phase, which is exactly // when the live language needs naming. Padded below the housing so it // clears the camera, and an overlay so it never changes the fixed - // window's size. - .overlay(alignment: .topLeading) { languageTag } + // window's size. Waveform + Draft places its own tag on the island. + .overlay(alignment: .topLeading) { + if !showsRecentDraft { languageTag } + } // Grow Down springs the island's height. Glyphs opt out so new // words land immediately; the token is coarse so a wrap is one spring. .animation( @@ -168,9 +170,57 @@ struct DictationHUDShellView: View { } private var bands: some View { + VStack(spacing: 0) { + island + if let label = shapingLabel { + HUDShapingLabel( + text: label.text, + palette: settings.glowPalette, + scale: metrics.scale, + activity: label.activity, + reduceMotion: reduceMotion + ) + .frame(height: metrics.shapingBandHeight) + } + } + } + + /// Housing plus the bands below it. Waveform + Draft's recent-word line + /// is an overlay on this silhouette, not a ZStack sibling: a + /// max-height-infinity child in a ZStack expands to the host window and + /// the black shape follows it. Overlay stays the stacked size, and the + /// words center in the box the glow wraps — including the housing + /// flanks, which are visible even though the camera sits in the middle. + private var island: some View { + stackedBands + .overlay { + if showsRecentDraft { + HUDRecentDraftText( + committed: content.text, + volatile: content.volatileText, + scale: metrics.scale + ) + .padding(.horizontal, tagInset * metrics.scale) + } + } + .overlay(alignment: .top) { + if showsRecentDraft, showsWaveDraftRibbon { + HUDCompactChartLineView(content: content, scale: metrics.scale) + .frame(height: metrics.ribbonBandHeight) + .padding(.top, HUDNotchGeometry.closedSize(for: screen).height) + .padding(.horizontal, tagInset * metrics.scale) + } + } + .overlay(alignment: .leading) { + if showsRecentDraft { languageTag } + } + } + + private var stackedBands: some View { VStack(spacing: 0) { // Strip level with the housing: kept empty so text never collides - // with the camera. + // with the camera. Waveform + Draft still counts it in the box the + // words are centered in, because the flanks of that strip are visible. Color.clear .frame(height: HUDNotchGeometry.closedSize(for: screen).height) if visualBandHeight > 0 { @@ -190,66 +240,43 @@ struct DictationHUDShellView: View { if showsTextBand { textBand } - if let label = shapingLabel { - HUDShapingLabel( - text: label.text, - palette: settings.glowPalette, - scale: metrics.scale, - activity: label.activity, - reduceMotion: reduceMotion - ) - .frame(height: metrics.shapingBandHeight) - } } } /// Draft text lives in the band below the housing. Compact puts its /// voice indicator on the leading side, Dynamic Island-style, with the - /// draft leading-aligned beside it. Waveform + Draft puts a thin Chart - /// Line flush under the housing and a recent-word line under that, so - /// the words get the strip a dedicated ribbon band would have occupied. + /// draft leading-aligned beside it. Waveform + Draft only reserves the + /// hanging stage here; the words themselves overlay the whole island. /// The other visuals center the draft. @ViewBuilder private var textBand: some View { - Group { - if showsRecentDraft { - VStack(alignment: .leading, spacing: 4 * metrics.scale) { - if showsWaveDraftRibbon { - HUDCompactChartLineView(content: content, scale: metrics.scale) - .frame(height: metrics.ribbonBandHeight) - } - HUDRecentDraftText( - committed: content.text, - volatile: content.volatileText, - scale: metrics.scale - ) - } - } else if showsLeadingDraft { - HStack(alignment: .top, spacing: 10 * metrics.scale) { - if showsCompactIndicator { - HUDCompactIndicatorView(content: content, scale: metrics.scale) - .padding(.top, 3 * metrics.scale) + if showsRecentDraft { + Color.clear + .frame(height: metrics.waveDraftStageHeight) + } else { + Group { + if showsLeadingDraft { + HStack(alignment: .top, spacing: 10 * metrics.scale) { + if showsCompactIndicator { + HUDCompactIndicatorView(content: content, scale: metrics.scale) + .padding(.top, 3 * metrics.scale) + } + compactDraftText + .frame(maxWidth: .infinity, alignment: .leading) } - compactDraftText - .frame(maxWidth: .infinity, alignment: .leading) + } else { + draftText } - } else { - draftText } + .font(.system(size: 15 * metrics.scale, weight: .medium)) + .foregroundStyle(.white) + // The tag sits in the inset rather than in the flow, and the inset grows + // on both sides, so centered drafts stay centered and nothing overlaps. + .padding(.horizontal, tagInset * metrics.scale) + .padding(.top, 9 * metrics.scale) + .padding(.bottom, 9 * metrics.scale) + .frame(minHeight: metrics.textBandHeight) } - .font(.system(size: 15 * metrics.scale, weight: .medium)) - .foregroundStyle(.white) - // The tag sits in the inset rather than in the flow, and the inset grows - // on both sides, so centered drafts stay centered and nothing overlaps. - .padding(.horizontal, tagInset * metrics.scale) - .padding(.top, showsRecentDraft ? 4 * metrics.scale : 9 * metrics.scale) - .padding(.bottom, 9 * metrics.scale) - // Waveform + Draft folds the old 24-point ribbon band into this - // minimum so the island does not shrink and the extra height is text. - .frame( - minHeight: metrics.textBandHeight - + (showsWaveDraftRibbon ? metrics.visualBandHeight : 0) - ) } private var showsRecentDraft: Bool { @@ -285,9 +312,9 @@ struct DictationHUDShellView: View { visual == .waveDraft && !reduceMotion && (listening || dismissing || shaping) } - /// Compact keeps its indicator through shaping and retracts; the ribbon - /// does the same, but a status message is a new occupant and must not - /// inherit the last session's audio or the extra 24 points of height. + /// Compact keeps its indicator through shaping and retracts; the recent + /// draft does the same, but a status message is a new occupant and must + /// not inherit the last session's audio or the extra 24 points of height. static func showsRibbon( visual: HUDVoiceVisualStyle, listening: Bool, @@ -343,6 +370,11 @@ struct DictationHUDShellView: View { if let tag = content.languageTag { tagCapsule(tag) .padding(.leading, 12 * metrics.scale) + // Waveform + Draft overlays the tag on the whole island, so it + // shares the draft's vertical center. Other visuals pin it below + // the housing from the shell. + .padding(.top, showsRecentDraft ? 0 : tagTopInset) + .allowsHitTesting(false) } } @@ -359,19 +391,12 @@ struct DictationHUDShellView: View { .padding(.horizontal, 4 * metrics.scale) .padding(.vertical, 1.5 * metrics.scale) .background(Capsule(style: .continuous).fill(.white.opacity(0.13))) - // Clears the housing, so a tag never sits beside the camera. When - // Waveform + Draft's ribbon or Reduce Motion's meter sits under the - // housing, the tag drops below that strip so it does not cover it. - .padding(.top, tagTopInset) - .allowsHitTesting(false) } private var tagTopInset: CGFloat { let housing = HUDNotchGeometry.closedSize(for: screen).height let belowHousing: CGFloat - if showsWaveDraftRibbon { - belowHousing = 4 * metrics.scale + metrics.ribbonBandHeight + 4 * metrics.scale - } else if showsTextBand, visualBandHeight > 0 { + if showsTextBand, visualBandHeight > 0 { belowHousing = visualBandHeight + 5 * metrics.scale } else { belowHousing = 5 * metrics.scale diff --git a/Talkify/Dictation/HUD/HUDRecentDraftText.swift b/Talkify/Dictation/HUD/HUDRecentDraftText.swift index d4eac21..30b1ac4 100644 --- a/Talkify/Dictation/HUD/HUDRecentDraftText.swift +++ b/Talkify/Dictation/HUD/HUDRecentDraftText.swift @@ -7,11 +7,36 @@ import SwiftUI struct HUDRecentDraftWindow: Equatable { static let wordLimit = 8 + struct VisibleToken: Equatable, Identifiable { + /// Index in the full joined draft, not in the window, so a word that + /// stays on screen keeps its identity across evictions and rewrites. + let id: Int + let text: String + } + let committed: String let volatile: String /// Character offset of the visible suffix in the joined draft. /// Advances when an old word is evicted; a volatile rewrite can move it back. let evictionOffset: Int + /// Word tokens in the joined draft, not the clipped window. A new word + /// raises this; an in-token volatile rewrite does not. + let tokenCount: Int + /// Visible slices, each carrying trailing whitespace through the next + /// token so a ForEach can keep surviving words in place. + let visibleTokens: [VisibleToken] + + /// The visible string, committed and volatile together. Waveform + Draft + /// paints them the same: the line is a glance, not a proofread. + var displayString: String { committed + volatile } + + var tokenIDs: [Int] { visibleTokens.map(\.id) } + + /// A new word or an eviction, not a volatile rewrite inside the current + /// token. Backward corrections snap; they must not linger behind a fade. + func advances(from previous: Self) -> Bool { + tokenCount > previous.tokenCount || evictionOffset > previous.evictionOffset + } init(committed: String, volatile: String, limit: Int = Self.wordLimit) { let joined = committed + volatile @@ -19,6 +44,8 @@ struct HUDRecentDraftWindow: Equatable { self.committed = "" self.volatile = "" self.evictionOffset = 0 + self.tokenCount = 0 + self.visibleTokens = [] return } @@ -29,12 +56,32 @@ struct HUDRecentDraftWindow: Equatable { tokens.append(range) return true } + tokenCount = tokens.count let start: String.Index + let firstVisible: Int if tokens.isEmpty || tokens.count <= limit { start = joined.startIndex + firstVisible = 0 + } else { + firstVisible = tokens.count - limit + start = tokens[firstVisible].lowerBound + } + + if tokens.isEmpty { + visibleTokens = [VisibleToken(id: 0, text: joined)] } else { - start = tokens[tokens.count - limit].lowerBound + var slices: [VisibleToken] = [] + for index in firstVisible.. CGFloat { + if bounds.width > 0, contentWidth > bounds.width { + return bounds.maxX - contentWidth + } + return bounds.midX - contentWidth / 2 + } +} + +/// Places each token at its ideal width so SwiftUI cannot compress them +/// separately. Overflow is then one clip of the oldest words, not eight +/// ellipses. +private struct HUDRecentDraftLineLayout: Layout { + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Void + ) -> CGSize { + let content = contentSize(subviews) + return CGSize( + width: proposal.width ?? content.width, + height: proposal.height ?? content.height + ) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout Void + ) { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let contentWidth = sizes.reduce(CGFloat.zero) { $0 + $1.width } + var x = HUDRecentDraftLine.originX(contentWidth: contentWidth, in: bounds) + for (subview, size) in zip(subviews, sizes) { + subview.place( + at: CGPoint(x: x, y: bounds.midY - size.height / 2), + anchor: .topLeading, + proposal: ProposedViewSize(size) + ) + x += size.width + } + } + + private func contentSize(_ subviews: Subviews) -> CGSize { + subviews.reduce(into: CGSize.zero) { acc, subview in + let size = subview.sizeThatFits(.unspecified) + acc.width += size.width + acc.height = max(acc.height, size.height) + } + } +} + +/// Waveform + Draft's live draft: one 24-point line of recent words, +/// centered in the island when it fits. Each word keeps a stable id, so +/// only a new or departing token slides; a finalization rewrites in place. struct HUDRecentDraftText: View { + static let pointSize: CGFloat = 24 + let committed: String let volatile: String var scale: CGFloat = 1 - @State private var lastEvictionOffset = 0 + @State private var lastWindow: HUDRecentDraftWindow? private var window: HUDRecentDraftWindow { HUDRecentDraftWindow(committed: committed, volatile: volatile) @@ -65,33 +171,25 @@ struct HUDRecentDraftText: View { var body: some View { let window = self.window - let movesOut = window.evictionOffset > lastEvictionOffset - ZStack(alignment: .leading) { - line(window) - .id(window.evictionOffset) - .transition(.asymmetric( - insertion: .identity, - removal: .offset(x: -8 * scale).combined(with: .opacity) - )) + let animate = lastWindow.map(window.advances(from:)) ?? false + HUDRecentDraftLineLayout { + ForEach(window.visibleTokens) { token in + Text(token.text) + .font(.system(size: Self.pointSize * scale, weight: .medium)) + .foregroundStyle(.white) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .transition(.asymmetric( + insertion: .move(edge: .trailing).combined(with: .opacity), + removal: .move(edge: .leading).combined(with: .opacity) + )) + } } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, maxHeight: .infinity) .clipped() - .animation(movesOut ? .easeOut(duration: 0.13) : nil, value: window.evictionOffset) - .onChange(of: window.evictionOffset) { _, new in - lastEvictionOffset = new + .animation(animate ? .easeOut(duration: 0.14) : nil, value: window.tokenIDs) + .onChange(of: window) { _, new in + lastWindow = new } } - - private func line(_ window: HUDRecentDraftWindow) -> some View { - let size = 18 * scale - var committed = AttributedString(window.committed) - committed.font = .system(size: size, weight: .medium) - committed.foregroundColor = .white - var guess = AttributedString(window.volatile) - guess.font = .system(size: size, weight: .regular) - guess.foregroundColor = Color.white.opacity(0.55) - return Text(committed + guess) - .lineLimit(1) - .truncationMode(.head) - } } diff --git a/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift b/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift index a10d7c3..1e85680 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift @@ -5,10 +5,11 @@ enum HUDVoiceVisualStyle: String, CaseIterable { /// A Metal shader waveform that fills the HUD's visual band. case waveform = "Waveform" - /// Compact's live draft and Edge Glow on the silhouette, with an optional - /// slim Chart Line under the housing. No five-bar indicator, particle - /// cloud, or orb — those sit on the words. The concert waveform styles - /// stay on Waveform; this composition is one designed Chart Line. + /// Centered recent-word live draft and Edge Glow on the silhouette, with + /// an optional slim Chart Line overlaid at the top of the same stage. No + /// five-bar indicator, particle cloud, or orb — those sit on the words. + /// The concert waveform styles stay on Waveform; this composition is one + /// designed Chart Line. case waveDraft = "Waveform + Draft" /// An origin glow hugging the shape's edge: it blooms out of the notch /// housing on session start, breathes with the voice, and drains back diff --git a/TalkifyTests/HUDMetricsTests.swift b/TalkifyTests/HUDMetricsTests.swift index 9d38cd8..c9b01ba 100644 --- a/TalkifyTests/HUDMetricsTests.swift +++ b/TalkifyTests/HUDMetricsTests.swift @@ -8,6 +8,7 @@ struct HUDMetricsTests { #expect(HUDMetrics.standard.contentWidth == 540) #expect(HUDMetrics.standard.waveBandHeight == 64) #expect(HUDMetrics.standard.ribbonBandHeight == 12) + #expect(HUDMetrics.standard.waveDraftStageHeight == 40) } @Test func everyDimensionScalesTogether() { @@ -20,6 +21,7 @@ struct HUDMetricsTests { #expect(half.visualBandHeight == full.visualBandHeight * 0.8) #expect(half.waveBandHeight == full.waveBandHeight * 0.8) #expect(half.ribbonBandHeight == full.ribbonBandHeight * 0.8) + #expect(half.waveDraftStageHeight == full.waveDraftStageHeight * 0.8) #expect(half.bottomCornerRadius == full.bottomCornerRadius * 0.8) } @@ -137,6 +139,12 @@ struct HUDShellMetricsTests { #expect(DictationHUDShellView.showsRecentDraft( visual: .waveDraft, listening: true, dismissing: false, shaping: false, reduceMotion: false)) + #expect(DictationHUDShellView.showsRecentDraft( + visual: .waveDraft, listening: true, dismissing: false, shaping: false, + reduceMotion: false)) + #expect(!DictationHUDShellView.showsRecentDraft( + visual: .waveDraft, listening: false, dismissing: false, shaping: false, + reduceMotion: false)) } @Test func aDraftLayoutIsHeldAtTheReadableFloorAndOthersAreNot() { diff --git a/TalkifyTests/HUDRecentDraftWindowTests.swift b/TalkifyTests/HUDRecentDraftWindowTests.swift index ff583b3..369937f 100644 --- a/TalkifyTests/HUDRecentDraftWindowTests.swift +++ b/TalkifyTests/HUDRecentDraftWindowTests.swift @@ -1,3 +1,4 @@ +import CoreGraphics import Testing @testable import Talkify @@ -11,7 +12,9 @@ struct HUDRecentDraftWindowTests { ) #expect(window.committed == "one two three") #expect(window.volatile == " four") + #expect(window.displayString == "one two three four") #expect(window.evictionOffset == 0) + #expect(window.tokenCount == 4) } @Test func exactlyEightTokensStayWhole() { @@ -22,6 +25,7 @@ struct HUDRecentDraftWindowTests { #expect(window.committed == "one two three four five six seven eight") #expect(window.volatile.isEmpty) #expect(window.evictionOffset == 0) + #expect(window.tokenCount == 8) } @Test func theNinthTokenEvictsTheFirst() { @@ -32,6 +36,7 @@ struct HUDRecentDraftWindowTests { #expect(window.committed == "two three four five six seven eight nine") #expect(window.volatile.isEmpty) #expect(window.evictionOffset > 0) + #expect(window.tokenCount == 9) } @Test func aPartialVolatileTokenCountsAsOneWord() { @@ -42,6 +47,7 @@ struct HUDRecentDraftWindowTests { #expect(window.committed == "one two three four five six seven ") #expect(window.volatile == "eigh") #expect(window.evictionOffset == 0) + #expect(window.tokenCount == 8) } @Test func aVolatileTokenCanCrossTheEvictionBoundary() { @@ -52,6 +58,7 @@ struct HUDRecentDraftWindowTests { #expect(window.committed == "two three four five six seven eight ") #expect(window.volatile == "nine") #expect(window.evictionOffset > 0) + #expect(window.tokenCount == 9) } @Test func punctuationAndSpacesStayInsideTheSuffix() { @@ -69,6 +76,7 @@ struct HUDRecentDraftWindowTests { #expect(window.committed == "— —") #expect(window.volatile == " !!!") #expect(window.evictionOffset == 0) + #expect(window.tokenCount == 0) } @Test func emptyInputIsEmpty() { @@ -76,5 +84,112 @@ struct HUDRecentDraftWindowTests { #expect(window.committed.isEmpty) #expect(window.volatile.isEmpty) #expect(window.evictionOffset == 0) + #expect(window.tokenCount == 0) + } + + @Test func aNewTokenAdvancesAndAnInTokenRewriteDoesNot() { + let two = HUDRecentDraftWindow(committed: "one two", volatile: "") + let three = HUDRecentDraftWindow(committed: "one two ", volatile: "thr") + let threeRewritten = HUDRecentDraftWindow(committed: "one two ", volatile: "three") + let four = HUDRecentDraftWindow(committed: "one two three four", volatile: "") + #expect(three.advances(from: two)) + #expect(!threeRewritten.advances(from: three)) + #expect(four.advances(from: threeRewritten)) + } + + @Test func theNinthTokenAdvancesByEviction() { + let eight = HUDRecentDraftWindow( + committed: "one two three four five six seven eight", + volatile: "" + ) + let nine = HUDRecentDraftWindow( + committed: "one two three four five six seven eight nine", + volatile: "" + ) + #expect(nine.advances(from: eight)) + } + + @Test func aBackwardRewriteDoesNotAdvance() { + let three = HUDRecentDraftWindow(committed: "one two three", volatile: "") + let two = HUDRecentDraftWindow(committed: "one two", volatile: "") + #expect(!two.advances(from: three)) + } + + @Test func visibleTokensKeepStableIDsAcrossAnEviction() { + let eight = HUDRecentDraftWindow( + committed: "one two three four five six seven eight", + volatile: "" + ) + let nine = HUDRecentDraftWindow( + committed: "one two three four five six seven eight nine", + volatile: "" + ) + #expect(eight.visibleTokens.map(\.id) == [0, 1, 2, 3, 4, 5, 6, 7]) + #expect(nine.visibleTokens.map(\.id) == [1, 2, 3, 4, 5, 6, 7, 8]) + #expect(eight.visibleTokens.map(\.text).joined() == eight.displayString) + #expect(nine.visibleTokens.map(\.text).joined() == nine.displayString) + } + + @Test func aFinalizationKeepsTheSameTokenID() { + let guessing = HUDRecentDraftWindow( + committed: "one two three four five six seven ", + volatile: "eigh" + ) + let finalized = HUDRecentDraftWindow( + committed: "one two three four five six seven eight", + volatile: "" + ) + #expect(guessing.tokenIDs == finalized.tokenIDs) + #expect(guessing.tokenIDs.count == 8) + } + + @Test func anInTokenRewriteKeepsTheSameTokenID() { + let partial = HUDRecentDraftWindow(committed: "one two ", volatile: "thr") + let longer = HUDRecentDraftWindow(committed: "one two ", volatile: "three") + #expect(partial.tokenIDs == longer.tokenIDs) + } + + /// Apple Speech can merge a volatile `can not` into a final `cannot`. + /// Token count drops and the window's ids reshuffle; that must snap, not + /// play the new-word slide. + @Test func aMergingFinalizationDoesNotAdvance() { + let guessing = HUDRecentDraftWindow( + committed: "one two three four five six seven ", + volatile: "can not" + ) + let finalized = HUDRecentDraftWindow( + committed: "one two three four five six seven cannot", + volatile: "" + ) + #expect(guessing.tokenIDs == [1, 2, 3, 4, 5, 6, 7, 8]) + #expect(finalized.tokenIDs == [0, 1, 2, 3, 4, 5, 6, 7]) + #expect(!finalized.advances(from: guessing)) + } + + @Test func aSplittingFinalizationAdvances() { + let guessing = HUDRecentDraftWindow( + committed: "one two three four five six seven ", + volatile: "cannot" + ) + let finalized = HUDRecentDraftWindow( + committed: "one two three four five six seven can not", + volatile: "" + ) + #expect(guessing.tokenIDs == [0, 1, 2, 3, 4, 5, 6, 7]) + #expect(finalized.tokenIDs == [1, 2, 3, 4, 5, 6, 7, 8]) + #expect(finalized.advances(from: guessing)) + } +} + +@Suite("Recent draft line overflow") +struct HUDRecentDraftLineTests { + @Test func aLineThatFitsIsCentered() { + let bounds = CGRect(x: 10, y: 0, width: 100, height: 40) + #expect(HUDRecentDraftLine.originX(contentWidth: 40, in: bounds) == 40) + } + + @Test func aLineThatOverflowsKeepsTheTrailingEdge() { + let bounds = CGRect(x: 10, y: 0, width: 100, height: 40) + #expect(HUDRecentDraftLine.originX(contentWidth: 140, in: bounds) == -30) } } diff --git a/TalkifyTests/HUDRenderTests.swift b/TalkifyTests/HUDRenderTests.swift index 5552e6d..befde5d 100644 --- a/TalkifyTests/HUDRenderTests.swift +++ b/TalkifyTests/HUDRenderTests.swift @@ -232,6 +232,56 @@ struct HUDRenderTests { _ = try render(card, named: "12-drop-preview-card") } + /// Ribbon on and off share the hanging stage; the Chart Line is only + /// pixels on top of it. The PNG canvas is the fixed host, so this is a + /// look-at fixture rather than a size assertion. + @Test func waveDraftRendersWithAndWithoutTheRibbon() throws { + for ribbon in [true, false] { + let store = AppSettings.previewStore() + store.voiceVisual = .waveDraft + store.waveDraftShowsRibbon = ribbon + let content = DictationHUDContent() + content.isRevealed = true + content.showsVoiceVisual = true + content.isAudioAlive = true + content.text = "one two three four five six seven eight" + content.languageTag = "EN" + + _ = try render( + DictationHUDShellView( + screen: notchedScreen, + settings: store.sessionSettings, + content: content + ), + named: ribbon ? "14-wave-draft-ribbon" : "15-wave-draft-text" + ) + } + } + + /// Ordinary eight-word drafts are wider than the island. This is a + /// look-at fixture for one overflow policy: the newest words stay + /// readable, the oldest clip, and no token ellipsizes on its own. + @Test func waveDraftRendersALongEightWordLine() throws { + let store = AppSettings.previewStore() + store.voiceVisual = .waveDraft + store.waveDraftShowsRibbon = false + let content = DictationHUDContent() + content.isRevealed = true + content.showsVoiceVisual = true + content.isAudioAlive = true + content.text = "please schedule another meeting with the marketing team" + content.languageTag = "EN" + + _ = try render( + DictationHUDShellView( + screen: notchedScreen, + settings: store.sessionSettings, + content: content + ), + named: "16-wave-draft-long-line" + ) + } + /// The menu bar ghost filling from the bottom as a file job advances, in the /// accent the drop surfaces are wearing. @Test(arguments: [SettingsTheme.accentColor, HUDGlowPalette.sunset.statusAccent]) diff --git a/docs/design/hud-anatomy.md b/docs/design/hud-anatomy.md index 7bbdb98..aa5e914 100644 --- a/docs/design/hud-anatomy.md +++ b/docs/design/hud-anatomy.md @@ -16,7 +16,7 @@ as the notch itself stretching downward. ``` screen top edge ───────┬─────────────────┬─────── - │▓▓▓ housing ▓▓▓ │ ← camera lives here, always empty + │▓▓▓ housing ▓▓▓ │ ← camera lives here; flanks can carry Waveform + Draft ╭──────┴─────────────────┴──────╮ │ │ ← visual band (voice visual) │ │ @@ -37,7 +37,8 @@ detached tabs. | Text band height | `36 * scale` min, `120 * scale` max | yes | | Visual band, Reduce Motion | `24 * scale` | yes | | Visual band, waveform / glow | `64 * scale` | yes | -| Waveform + Draft ribbon | `12 * scale`, inside the text band | yes | +| Waveform + Draft hanging stage | `40 * scale` | yes | +| Waveform + Draft ribbon | `12 * scale`, inside the hanging stage | yes | | Bottom corner radius | `20 * scale` | yes | | Housing, measured notch | whatever the display reports | **no** | | Housing, simulated | `185 × 32` | **no** | @@ -54,8 +55,12 @@ bezel curve. ## The bands -**Housing band.** Always present, always empty. Its height is the notch's -height. Nothing is ever drawn here — content would sit behind the camera. +**Housing band.** Always present. Its height is the notch's height. The +camera sits in the middle of this strip, so Compact, Waveform, Edge Glow, +and status messages leave it empty. Waveform + Draft is the exception: its +recent-word line is an overlay centered on the housing-plus-stage +silhouette, because the flanks of that strip are still visible beside the +camera. The camera itself stays empty. **Visual band.** Holds the voice visual. Its height depends on which visual is selected: `64 * scale` for Waveform, `64 * scale` (empty, used as a stage @@ -65,16 +70,21 @@ Chart Line ribbon live inside the text band instead. **Text band.** Holds live draft text. Present for Compact and Waveform + Draft, absent for Waveform and Edge Glow while listening, and always present -under Reduce Motion. Draft text is 15pt medium white, centered except on -Compact and Waveform + Draft where it is leading-aligned, with three -long-draft behaviors the user picks between: single line with head -truncation, wrap and grow downward capped at four lines, or single line that -shrinks to fit. - -**Language tag.** A small capsule pinned to the top leading corner below the -housing, shown only when a second dictation language is configured. It hangs -off the shell as an overlay rather than sitting in the layout, so it never -changes the window's size. +under Reduce Motion. Draft text is 15pt medium white and centered, except +Compact (leading, beside the five-bar indicator) and Waveform + Draft (a +`24 * scale` recent-word line centered in the island, overlaying housing +plus a `40 * scale` hanging stage). Compact keeps the three long-draft +behaviors the user picks between: single line with head truncation, wrap +and grow downward capped at four lines, or single line that shrinks to +fit. Waveform + Draft does not: eight tokens on one line, and a line that +does not fit clips the oldest words instead of ellipsizing each one. + +**Language tag.** A small capsule shown only when a second dictation +language is configured. It hangs off the shell as an overlay rather than +sitting in the layout, so it never changes the window's size. Compact, +Waveform, Edge Glow, and status messages pin it to the top leading corner +below the housing. Waveform + Draft overlays it on the leading edge of the +island, vertically centered with the draft. ## Hard rules that keep being violated @@ -89,7 +99,11 @@ itself inside the same fixed window. overshoot lifts the shape off the screen edge and opens a visible gap above it. Scale anchored at `.top` cannot. -**Nothing is drawn in the housing band.** +**Nothing is drawn in the housing band**, except Waveform + Draft's +recent-word overlay, which is centered on the housing-plus-stage +silhouette so the line sits in the visible flanks rather than only under +the camera. Status messages, Compact, Waveform, and Edge Glow still leave +the housing empty. **Fillets require a real notch.** Zero otherwise. diff --git a/docs/design/voice-visuals.md b/docs/design/voice-visuals.md index 9aa70d5..50e1270 100644 --- a/docs/design/voice-visuals.md +++ b/docs/design/voice-visuals.md @@ -30,16 +30,18 @@ after the iOS Dynamic Island caption look. Bars are 2.5pt wide capsules, 2.5pt apart, resting at 3pt and reaching about 14pt. The shape grows with the text. -**Waveform + Draft** — Compact's leading live draft, but only the last -eight word tokens on one `18 * scale` line, with Edge Glow on the -silhouette. A `12 * scale` Chart Line ribbon can sit at the top of the -text band, just under the housing; it is on by default and can be turned -off. No five-bar indicator, particle cloud, or orb. +**Waveform + Draft** — the last eight word tokens on one `24 * scale` +line centered in the island (housing plus a `40 * scale` stage), with +Edge Glow on the silhouette. A `12 * scale` Chart Line ribbon can sit +under the housing; it is on by default and can be turned off without +shrinking the box. No five-bar indicator, particle cloud, or orb. The concert waveform styles stay on Waveform; this pick is one designed Chart Line. Glow palette colours the beam, the shaping caption, and the status ghost. Putting the draft on top of the 64-point waveform was prototyped and removed; this is the other direction: the ribbon shares the -draft's band so the words sit higher. +draft's band so the words sit in the same center either way. New and +departing tokens slide; a finalization does not fade the line. A line +that does not fit keeps the newest words readable and clips the oldest. Under Reduce Motion all four are replaced by a quiet level meter in the slim `24 * scale` band, and the draft text always shows. From b038b2e8c4be7153f4861bbb64be9c126714ebb6 Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Fri, 4 Sep 2026 13:15:49 -0700 Subject: [PATCH 09/11] Fold Compact's indicator into the leading draft The extra Compact flag was the same predicate as the leading-draft layout, so it never hid the indicator. The ribbon-over-words overlap is the feel-tested Waveform + Draft look, so it is called out as kept. --- Talkify/Dictation/HUD/DictationHUDShellView.swift | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/Talkify/Dictation/HUD/DictationHUDShellView.swift b/Talkify/Dictation/HUD/DictationHUDShellView.swift index 7ffc56b..5a3c7be 100644 --- a/Talkify/Dictation/HUD/DictationHUDShellView.swift +++ b/Talkify/Dictation/HUD/DictationHUDShellView.swift @@ -116,11 +116,6 @@ struct DictationHUDShellView: View { settings.voiceVisual == .compact && !reduceMotion } - private var showsCompactIndicator: Bool { - settings.voiceVisual == .compact && !reduceMotion - } - - private var filletSize: CGFloat { HUDNotchGeometry.filletSize(for: screen) } @@ -203,6 +198,9 @@ struct DictationHUDShellView: View { .padding(.horizontal, tagInset * metrics.scale) } } + // The ribbon sits at the top of the stage and the words are centered in + // the whole island, so the line crosses them and paints over the glyphs. + // That is the chosen look, not an oversight: reviewed and kept. .overlay(alignment: .top) { if showsRecentDraft, showsWaveDraftRibbon { HUDCompactChartLineView(content: content, scale: metrics.scale) @@ -257,10 +255,8 @@ struct DictationHUDShellView: View { Group { if showsLeadingDraft { HStack(alignment: .top, spacing: 10 * metrics.scale) { - if showsCompactIndicator { - HUDCompactIndicatorView(content: content, scale: metrics.scale) - .padding(.top, 3 * metrics.scale) - } + HUDCompactIndicatorView(content: content, scale: metrics.scale) + .padding(.top, 3 * metrics.scale) compactDraftText .frame(maxWidth: .infinity, alignment: .leading) } From ed1e055d62e766066bea54cb13690cef4dfa968d Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Sat, 5 Sep 2026 12:37:26 -0700 Subject: [PATCH 10/11] Rename Waveform + Draft to Edge Glow + Draft The silhouette is the glow, not a concert waveform. The Chart Line ribbon and its Settings toggle are gone: this visual is the version without it. A stored Waveform + Draft pick still loads as Edge Glow + Draft. --- CONTEXT.md | 22 ++--- Talkify/App/AppSettings.swift | 17 +--- Talkify/CoreHUD/HUDMetrics.swift | 10 +-- .../HUD/DictationHUDController.swift | 2 +- .../Dictation/HUD/DictationHUDShellView.swift | 84 +++++-------------- .../Dictation/HUD/HUDRecentDraftText.swift | 4 +- .../HUD/Visuals/HUDCompactChartLineView.swift | 78 ----------------- .../HUD/Visuals/HUDVisualTokens.swift | 3 +- .../HUD/Visuals/HUDVoiceVisualStyle.swift | 30 ++++--- .../DropTranscription/HUD/DropHUDStyle.swift | 2 +- .../Sections/AppearanceSettingsView.swift | 26 ++---- TalkifyTests/AppSettingsTests.swift | 18 ++-- TalkifyTests/HUDMetricsTests.swift | 52 +++++------- TalkifyTests/HUDNotchGeometryTests.swift | 26 ++---- TalkifyTests/HUDPlaceholderTests.swift | 29 +++---- TalkifyTests/HUDRenderTests.swift | 49 +++++------ TalkifyTests/SettingsWindowTests.swift | 13 ++- docs/design/FEATURE.md | 2 +- docs/design/hud-anatomy.md | 21 +++-- docs/design/voice-visuals.md | 20 ++--- docs/settings-ui-spec.md | 4 +- 21 files changed, 167 insertions(+), 345 deletions(-) delete mode 100644 Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift diff --git a/CONTEXT.md b/CONTEXT.md index 10e599e..6a5eabc 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -111,7 +111,7 @@ _Avoid_: Transcript history, cloud analytics - Releasing the **Dictation Trigger** before 250 milliseconds counts as a quick tap - A quick tap leaves **Direct Dictation** active until the next trigger press - The **Direct Dictation** HUD shows live draft text while speech continues, unless the selected voice visual replaces it; with Reduce Motion the draft text always shows -- The HUD renders volatile recognition results immediately, so new text can show before it commits. Compact and the Reduce Motion band draw them lighter than finalized words; Waveform + Draft paints both the same, because that line is a glance, not a proofread +- The HUD renders volatile recognition results immediately, so new text can show before it commits. Compact and the Reduce Motion band draw them lighter than finalized words; Edge Glow + Draft paints both the same, because that line is a glance, not a proofread - **Direct Dictation** inserts finalized recognition text plus the latest volatile result when recognition ends before finalizing it - The **Direct Dictation** HUD expands from the physical notch on the focused input's display - Settings exposes **Appearance**, **Sounds**, and **Insights** in that order @@ -128,7 +128,7 @@ _Avoid_: Transcript history, cloud analytics - **HUD size** is a Settings slider from 20% to 100% in 5% steps, defaulting to 100%, and applies on every display rather than only where there is no notch - A display raises its own **HUD size** floor when the picked size would leave the shape narrower than the housing it descends from plus its fillets: a shape narrower than its housing reads as a tab floating under the notch, and its fillets land inside the cutout with no bezel to meet - That floor cannot be one number: a notch is a fixed physical width but its width in points moves with the scaled display mode, so the same MacBook reports roughly 155 points under More Space and 273 under Larger Text; a display with no notch keeps the slider's own minimum, which is where a smaller HUD is wanted because every point the shape covers is screen the user was working in -- A HUD that will show draft text has a second floor at 40%, where the draft is still 6 points: Compact and Waveform + Draft are built around the live draft and Reduce Motion restores it for every visual, so those stop there while Waveform and Edge Glow, which replace the draft entirely, go down to 20% +- A HUD that will show draft text has a second floor at 40%, where the draft is still 6 points: Compact and Edge Glow + Draft are built around the live draft and Reduce Motion restores it for every visual, so those stop there while Waveform and Edge Glow, which replace the draft entirely, go down to 20% - The **HUD size** slider offers only the sizes the selected voice visual can be shown at, and the higher of the two floors wins whenever both apply - A size below the selected visual's floor stays persisted and returns when a visual that can show it is picked again - The housing band and the fillets never scale with **HUD size**: the band's height is the physical notch on a notched display and the menu bar's clearance elsewhere, and a fillet exists to meet a physical bezel @@ -142,7 +142,7 @@ _Avoid_: Transcript history, cloud analytics - HUD status and error messages dismiss themselves after about two seconds - The HUD writes nothing while it retracts and its bands do not resize: the shape leaves exactly as it stood, and its text and layout reset only once it is off screen - The wait between the last word and the recognized text is silent — no label stands in for it, and the voice visual comes to rest rather than reading the silence as a dead microphone -- Compact and Waveform + Draft open with no text at all, because they are built around the live draft and a placeholder would be words nobody spoke +- Compact and Edge Glow + Draft open with no text at all, because they are built around the live draft and a placeholder would be words nobody spoke - The HUD's shaders and the particle cloud's compute kernels are compiled at launch, so the cost never lands on the first frames of a session - Talkify holds a user-initiated activity assertion while a **Direct Dictation** session or a **Drop Transcription** job runs, and holds none while idle: a menu-bar-only app with no window is what App Nap targets, and a napped process draws the HUD's animation clock too slowly to watch - The assertion is taken before the begin guards run, because the frames a napped process draws badly are the first ones; every guard that refuses a session releases it again @@ -150,16 +150,16 @@ _Avoid_: Transcript history, cloud analytics - Settings changes apply to the live app and persist immediately; Settings has no Save step - The Appearance preview uses the same preferences and HUD surface as Direct Dictation - Settings changes update the Appearance preview immediately, while an active Direct Dictation session keeps the choices captured at session start -- Dictation session settings include the voice visual, waveform style, glow palette, glow center, Waveform + Draft's Chart Line ribbon, reveal style, long-draft behavior, HUD size, sound set, sound enabled state, sound volume, insertion destination, the transcription history choice, the shaping choice, and whether the session lowers other audio +- Dictation session settings include the voice visual, waveform style, glow palette, glow center, reveal style, long-draft behavior, HUD size, sound set, sound enabled state, sound volume, insertion destination, the transcription history choice, the shaping choice, and whether the session lowers other audio - **Direct Dictation** can lower other audio while it listens and put it back when the session ends, cancels or fails; it is off by default because the control it moves is system-wide - macOS has no per-application ducking, so lowering other audio moves the default output device's own volume and quiets Talkify's session sounds along with everything else - A lowered volume is restored only while it is still the value Talkify set: a volume the user changed mid-session is theirs, the same rule the clipboard restore follows - The device that was lowered is the one restored, not whichever output is default when the session ends: the default can change mid-session, and the volume that needs putting back belongs to the device that was quieted - An output with no settable volume does not duck, and neither does one already at silence - A third voice visual, Compact, shows a small five-bar voice indicator beside the leading-aligned live draft inside the shape, after the iOS Dynamic Island caption look; the shape grows with the draft -- A fourth voice visual, Waveform + Draft, keeps a live draft of the last eight word tokens at 24-point type, centered in the island (housing plus a 40-point stage), and wraps the shape in Edge Glow. A slim Chart Line ribbon can sit under the housing; it is on by default and can be turned off without shrinking the box. It has no particle cloud and no orb. Each visible word keeps a stable identity: a new token slides in from the right, a departing token slides off to the left, and a finalization rewrites the same token in place. A line that does not fit clips the oldest words rather than ellipsizing each one. Insertion still uses the full transcript. -- Compact and Waveform + Draft show the live draft while listening; Waveform and Edge Glow replace the draft text entirely -- An In the Shape live-draft placement for Waveform and Edge Glow was prototyped and removed; putting words on the concert waveform stays rejected. Waveform + Draft is the chosen live-draft direction: the draft has its own band under a slim ribbon, in one shape +- A fourth voice visual, Edge Glow + Draft, keeps a live draft of the last eight word tokens at 24-point type, centered in the island (housing plus a 40-point stage), and wraps the shape in Edge Glow. It has no Chart Line ribbon, particle cloud, or orb. Each visible word keeps a stable identity: a new token slides in from the right, a departing token slides off to the left, and a finalization rewrites the same token in place. A line that does not fit clips the oldest words rather than ellipsizing each one. Insertion still uses the full transcript. +- Compact and Edge Glow + Draft show the live draft while listening; Waveform and Edge Glow replace the draft text entirely +- An In the Shape live-draft placement for Waveform and Edge Glow was prototyped and removed; putting words on the concert waveform stays rejected. Edge Glow + Draft is the chosen live-draft direction: the draft has its own hanging stage in one shape, without a Chart Line ribbon - A detached child pill under the island (the uni-pills goo bubble) was prototyped as a third placement and rejected by feel: a second detached surface does not fit the single-shape design - Reduce Motion overrides every live draft placement with the plain text band and quiet level meter - `AppSettings` is the only persisted source of truth for preferences; the Dictation controller creates Dictation session settings for the HUD at session start @@ -277,7 +277,7 @@ _Avoid_: Transcript history, cloud analytics - **Insights** shows words, sessions, speaking time, weighted words per minute, Voice Momentum, 14-day activity, streaks, and a 16-week heatmap - Settings lets the user choose the session sounds, mute them, adjust their volume, choose the voice-reactive visual, the waveform style, and the edge glow's color palette - Insertion latency must be benchmarked before choosing permanent per-application defaults -- Mid-session insertion was prototyped (streamed finalized chunks; a ghost overlay over the focused input) and rejected: insertion stays at session end. The live-draft direction for combining Waveform and Edge Glow with the draft is Waveform + Draft, not mid-session insertion and not words over the concert waveform +- Mid-session insertion was prototyped (streamed finalized chunks; a ghost overlay over the focused input) and rejected: insertion stays at session end. The live-draft direction for combining Waveform and Edge Glow with the draft is Edge Glow + Draft, not mid-session insertion and not words over the concert waveform - A marked-text input method (system-dictation-style provisional text in any field) remains a considered route, deferred: it needs a separate installable input-source bundle, System Settings enablement, and cross-process wiring - **Read Aloud** speaks the focused application's selected text with Apple speech synthesis, on-device and offline; Siri voices are unavailable to third-party apps - Read Aloud reads the selection through Accessibility, and by copying it when Accessibility answers nothing, which is what every browser does; if neither finds anything it shows "No text selected" through the HUD and speaks nothing @@ -407,7 +407,7 @@ _Avoid_: Transcript history, cloud analytics - The sidebar retains the `SETTINGS` group label even when the header also shows Settings; it distinguishes navigation groups - The Talkify ghost is the app icon, the status item icon (template-rendered so it follows the menu bar theme), and the Settings header icon - While Direct Dictation listens, the status item ghost pulses between full and dimmed tint; the pulse stops when the session ends -- An Edge Glow or Waveform + Draft session tints the status ghost pulse with its captured palette's accent; Waveform and Compact sessions keep the theme tint, and the idle ghost stays template-rendered +- An Edge Glow or Edge Glow + Draft session tints the status ghost pulse with its captured palette's accent; Waveform and Compact sessions keep the theme tint, and the idle ghost stays template-rendered - The Settings surface uses a 16-point continuous radius, a one-pixel low-opacity border, and a soft shadow - Settings may open during Direct Dictation; the active session remains on its captured settings and changes apply next session - While Direct Dictation is active, Settings shows a small contextual notice that changes apply to the next session; idle Settings has no notice @@ -424,5 +424,5 @@ _Avoid_: Transcript history, cloud analytics - Settings navigation uses a fixed Talkify blue accent; selected Glow palettes do not recolor the Settings shell - "subtitles" was used for text displayed during a Google Meet call — resolved: this is **Live Captions**. -- HUD behavior for long drafts — resolved: the downward-growing panel won the feel test against tail-only truncation and shrink-to-fit. The HUD wraps long drafts and grows downward, capped at four lines; the other two variants remain selectable for the Settings picker. Waveform + Draft is the exception: it shows the last eight word tokens on one 24-point centered line instead of those behaviors, because wrapping fights a larger type in the stage. Reduce Motion restores the plain band, where the global pick still applies. -- The voice-reactive visual — resolved: Waveform and Edge Glow ship and replace draft text while listening; Compact shows the live draft with a five-bar indicator; Waveform + Draft is the fourth pick, a designed combo of slim Chart Line, live draft, and Edge Glow chrome with no glow center. The waveform (default: the Chart Line conveyor with metallic silver treatment, plus seven alternate styles) fills the visual band; Waveform + Draft does not offer those styles. The edge glow is a three-part animation — a palette-gradient beam whose bright region blooms in from under the housing on session start, sweeps the shape's open silhouette while listening, follows the voice in brightness and stroke thickness (the feel-test pick over reach, particle count, particle size, and syllable bursts), and drains back when the session ends, a one-shot ripple wave rolling across the housing at session start, and a particle cloud in the palette's colors chasing the beam's sweeping origin. Waveform + Draft keeps the beam and the ripple and drops the center. The palette (Spectrum, Silver, Aurora, Sunset, Ocean, Mono) is a Settings pick coloring beam and particles together, and the shaping caption. +- HUD behavior for long drafts — resolved: the downward-growing panel won the feel test against tail-only truncation and shrink-to-fit. The HUD wraps long drafts and grows downward, capped at four lines; the other two variants remain selectable for the Settings picker. Edge Glow + Draft is the exception: it shows the last eight word tokens on one 24-point centered line instead of those behaviors, because wrapping fights a larger type in the stage. Reduce Motion restores the plain band, where the global pick still applies. +- The voice-reactive visual — resolved: Waveform and Edge Glow ship and replace draft text while listening; Compact shows the live draft with a five-bar indicator; Edge Glow + Draft is the fourth pick, live draft plus Edge Glow chrome with no glow center and no Chart Line. The waveform (default: the Chart Line conveyor with metallic silver treatment, plus seven alternate styles) fills the visual band; Edge Glow + Draft does not offer those styles. The edge glow is a three-part animation — a palette-gradient beam whose bright region blooms in from under the housing on session start, sweeps the shape's open silhouette while listening, follows the voice in brightness and stroke thickness (the feel-test pick over reach, particle count, particle size, and syllable bursts), and drains back when the session ends, a one-shot ripple wave rolling across the housing at session start, and a particle cloud in the palette's colors chasing the beam's sweeping origin. Edge Glow + Draft keeps the beam and the ripple and drops the center. The palette (Spectrum, Silver, Aurora, Sunset, Ocean, Mono) is a Settings pick coloring beam and particles together, and the shaping caption. diff --git a/Talkify/App/AppSettings.swift b/Talkify/App/AppSettings.swift index 659dc04..ca4579d 100644 --- a/Talkify/App/AppSettings.swift +++ b/Talkify/App/AppSettings.swift @@ -23,7 +23,6 @@ final class AppSettings { static let longDraftStyle = "hudLongDraftStyle" static let glowPalette = "hudGlowPalette" static let glowCenter = "hudGlowCenter" - static let waveDraftShowsRibbon = "hudWaveDraftShowsRibbon" static let hudScale = "hudScale" static let readAloudVoice = "readAloudVoice" static let readAloudTranslates = "readAloudTranslates" @@ -162,13 +161,6 @@ final class AppSettings { didSet { defaults.set(glowCenter.rawValue, forKey: Keys.glowCenter) } } - /// Waveform + Draft's slim Chart Line under the housing. Off keeps the - /// live draft and the glow. Default on, so an upgrade does not strip - /// the ribbon from a pick that already includes it. - var waveDraftShowsRibbon: Bool { - didSet { defaults.set(waveDraftShowsRibbon, forKey: Keys.waveDraftShowsRibbon) } - } - /// How large the HUD shape is, as a fraction of the standard size. /// `HUDMetrics` clamps it to its supported range; a stored value outside /// that range comes back clamped rather than refused. @@ -318,14 +310,13 @@ final class AppSettings { promptShapingPromptID = defaults.string(forKey: Keys.promptShapingPrompt) ?? ShapingPrompt.defaults[0].id shapingPrompts = Self.storedShapingPrompts(in: defaults) ?? ShapingPrompt.defaults - voiceVisual = Self.stored(in: defaults, key: Keys.voiceVisual) ?? .waveform + voiceVisual = defaults.string(forKey: Keys.voiceVisual).flatMap(HUDVoiceVisualStyle.init(stored:)) + ?? .waveform waveformStyle = Self.stored(in: defaults, key: Keys.waveformStyle) ?? .chartLine revealStyle = Self.stored(in: defaults, key: Keys.revealStyle) ?? .slide longDraftStyle = Self.stored(in: defaults, key: Keys.longDraftStyle) ?? .growDown glowPalette = Self.stored(in: defaults, key: Keys.glowPalette) ?? .spectrum glowCenter = Self.stored(in: defaults, key: Keys.glowCenter) ?? .particles - waveDraftShowsRibbon = - defaults.object(forKey: Keys.waveDraftShowsRibbon) as? Bool ?? true // `double(forKey:)` reads a missing key as zero, which would start every // existing user at the smallest HUD, so absence is checked directly. hudScale = defaults.object(forKey: Keys.hudScale) as? Double @@ -477,11 +468,10 @@ struct DictationSessionSettings: Equatable { let longDraftStyle: HUDLongDraftStyle let glowPalette: HUDGlowPalette let glowCenter: HUDGlowCenterStyle - let waveDraftShowsRibbon: Bool let hudMetrics: HUDMetrics /// The colour a Drop Transcription wears — on the HUD's target and card, and - /// on the status ghost while a file job fills it. Edge Glow and Waveform + + /// on the status ghost while a file job fills it. Edge Glow and Edge Glow + /// Draft lend the palette's own hue; every other visual keeps Talkify blue. /// One definition, so the shape and the menu bar can never drift apart /// (CONTEXT.md). @@ -511,7 +501,6 @@ struct DictationSessionSettings: Equatable { longDraftStyle = settings.longDraftStyle glowPalette = settings.glowPalette glowCenter = settings.glowCenter - waveDraftShowsRibbon = settings.waveDraftShowsRibbon hudMetrics = HUDMetrics(scale: CGFloat(settings.hudScale)) } } diff --git a/Talkify/CoreHUD/HUDMetrics.swift b/Talkify/CoreHUD/HUDMetrics.swift index 9868e57..05882cf 100644 --- a/Talkify/CoreHUD/HUDMetrics.swift +++ b/Talkify/CoreHUD/HUDMetrics.swift @@ -27,7 +27,7 @@ struct HUDMetrics: Equatable { /// sizes beneath it are for the visuals that replace the draft entirely. static let minimumReadableScale: CGFloat = 0.4 - /// The floor for a given voice visual. Compact and Waveform + Draft are + /// The floor for a given voice visual. Compact and Edge Glow + Draft are /// built around the live draft, and Reduce Motion restores the draft for /// every visual, so those stop where the text stops being legible. Waveform /// and Edge Glow replace the draft entirely and go all the way down. @@ -64,14 +64,10 @@ struct HUDMetrics: Equatable { /// shown between the housing and the text band. var visualBandHeight: CGFloat { 24 * scale } - /// Height of the Waveform + Draft Chart Line, drawn at the top of the - /// text band rather than as a band of its own, so the words sit higher. - var ribbonBandHeight: CGFloat { 12 * scale } - - /// Waveform + Draft hanging band: 24-point type plus 8 points above + /// Edge Glow + Draft hanging band: 24-point type plus 8 points above /// and below. Tighter than the 36+24 stage that left empty air in the /// pill; still tall enough that the line does not kiss the stroke. - var waveDraftStageHeight: CGFloat { 40 * scale } + var glowDraftStageHeight: CGFloat { 40 * scale } /// Height of the waveform band. The waveform variant replaces the draft /// text entirely, so it gets room to breathe. diff --git a/Talkify/Dictation/HUD/DictationHUDController.swift b/Talkify/Dictation/HUD/DictationHUDController.swift index a556672..5feb8a5 100644 --- a/Talkify/Dictation/HUD/DictationHUDController.swift +++ b/Talkify/Dictation/HUD/DictationHUDController.swift @@ -95,7 +95,7 @@ final class DictationHUDController { /// What the band says before any words arrive. /// - /// Empty for Compact and Waveform + Draft: both are built around the live + /// Empty for Compact and Edge Glow + Draft: both are built around the live /// draft, so a placeholder there is words nobody spoke. Every path that /// would write one asks here, rather than each deciding for itself — which /// is how "Listening (latched)" kept coming back after the opening text was diff --git a/Talkify/Dictation/HUD/DictationHUDShellView.swift b/Talkify/Dictation/HUD/DictationHUDShellView.swift index 5a3c7be..f396706 100644 --- a/Talkify/Dictation/HUD/DictationHUDShellView.swift +++ b/Talkify/Dictation/HUD/DictationHUDShellView.swift @@ -47,12 +47,12 @@ struct DictationHUDShellView: View { HUDNotchGeometry.contentSize( for: screen, metrics: metrics, - // Waveform + Draft has no visual band; its hanging stage is 40 + // Edge Glow + Draft has no visual band; its hanging stage is 40 // points, 4 more than the ordinary text band. That extra has to be // in the declared size or Slide parks short of hiding the island. visualBandHeight: visualBandHeight + (showsRecentDraft - ? metrics.waveDraftStageHeight - metrics.textBandHeight : 0), + ? metrics.glowDraftStageHeight - metrics.textBandHeight : 0), includesTextBand: showsTextBand, shapingBandHeight: showsShapingLabel ? metrics.shapingBandHeight : 0 ) @@ -80,22 +80,22 @@ struct DictationHUDShellView: View { } /// Reduce Motion always shows the quiet level meter in its slim band. - /// Compact and Waveform + Draft have no band of their own: Compact's - /// indicator and Waveform + Draft's Chart Line live inside the text band, - /// so the words start higher. Waveform and Edge Glow keep the tall band — + /// Compact and Edge Glow + Draft have no band of their own: Compact's + /// indicator lives in the text band, and Edge Glow + Draft's words overlay + /// the island. Waveform and Edge Glow keep the tall band — /// the waveform fills it, the glow keeps it as an empty stage so the /// silhouette has flanks for the light to wrap. private var visualBandHeight: CGFloat { guard keepsVisualLayout else { return 0 } if reduceMotion { return metrics.visualBandHeight } switch settings.voiceVisual { - case .compact, .waveDraft: return 0 + case .compact, .glowDraft: return 0 case .waveform, .glow: return metrics.waveBandHeight } } /// Waveform and Edge Glow replace the draft text entirely while - /// listening; Compact and Waveform + Draft are built around it. With + /// listening; Compact and Edge Glow + Draft are built around it. With /// Reduce Motion the draft text always shows. private var showsTextBand: Bool { if !keepsVisualLayout || reduceMotion { return true } @@ -111,7 +111,7 @@ struct DictationHUDShellView: View { /// Whether the text band is Compact's leading-aligned draft. Not gated on /// the listening state — swapping the band's structure at finalize reads /// as a glitch mid retract, so the layout stays and the indicator settles - /// instead. Waveform + Draft has its own centered stage. + /// instead. Edge Glow + Draft has its own centered stage. private var showsLeadingDraft: Bool { settings.voiceVisual == .compact && !reduceMotion } @@ -143,7 +143,7 @@ struct DictationHUDShellView: View { // Glow hide the band for the whole listening phase, which is exactly // when the live language needs naming. Padded below the housing so it // clears the camera, and an overlay so it never changes the fixed - // window's size. Waveform + Draft places its own tag on the island. + // window's size. Edge Glow + Draft places its own tag on the island. .overlay(alignment: .topLeading) { if !showsRecentDraft { languageTag } } @@ -180,7 +180,7 @@ struct DictationHUDShellView: View { } } - /// Housing plus the bands below it. Waveform + Draft's recent-word line + /// Housing plus the bands below it. Edge Glow + Draft's recent-word line /// is an overlay on this silhouette, not a ZStack sibling: a /// max-height-infinity child in a ZStack expands to the host window and /// the black shape follows it. Overlay stays the stacked size, and the @@ -198,17 +198,6 @@ struct DictationHUDShellView: View { .padding(.horizontal, tagInset * metrics.scale) } } - // The ribbon sits at the top of the stage and the words are centered in - // the whole island, so the line crosses them and paints over the glyphs. - // That is the chosen look, not an oversight: reviewed and kept. - .overlay(alignment: .top) { - if showsRecentDraft, showsWaveDraftRibbon { - HUDCompactChartLineView(content: content, scale: metrics.scale) - .frame(height: metrics.ribbonBandHeight) - .padding(.top, HUDNotchGeometry.closedSize(for: screen).height) - .padding(.horizontal, tagInset * metrics.scale) - } - } .overlay(alignment: .leading) { if showsRecentDraft { languageTag } } @@ -217,7 +206,7 @@ struct DictationHUDShellView: View { private var stackedBands: some View { VStack(spacing: 0) { // Strip level with the housing: kept empty so text never collides - // with the camera. Waveform + Draft still counts it in the box the + // with the camera. Edge Glow + Draft still counts it in the box the // words are centered in, because the flanks of that strip are visible. Color.clear .frame(height: HUDNotchGeometry.closedSize(for: screen).height) @@ -243,14 +232,14 @@ struct DictationHUDShellView: View { /// Draft text lives in the band below the housing. Compact puts its /// voice indicator on the leading side, Dynamic Island-style, with the - /// draft leading-aligned beside it. Waveform + Draft only reserves the + /// draft leading-aligned beside it. Edge Glow + Draft only reserves the /// hanging stage here; the words themselves overlay the whole island. /// The other visuals center the draft. @ViewBuilder private var textBand: some View { if showsRecentDraft { Color.clear - .frame(height: metrics.waveDraftStageHeight) + .frame(height: metrics.glowDraftStageHeight) } else { Group { if showsLeadingDraft { @@ -285,19 +274,8 @@ struct DictationHUDShellView: View { ) } - private var showsWaveDraftRibbon: Bool { - Self.showsRibbon( - visual: settings.voiceVisual, - listening: content.showsVoiceVisual, - dismissing: content.isDismissing, - shaping: content.shapingName != nil, - reduceMotion: reduceMotion, - ribbonEnabled: settings.waveDraftShowsRibbon - ) - } - - /// Waveform + Draft's recent-word line. Same occupancy as the ribbon: - /// listening, retracting, and shaping keep it; a status message does not. + /// Edge Glow + Draft's recent-word line: listening, retracting, and + /// shaping keep it; a status message does not. static func showsRecentDraft( visual: HUDVoiceVisualStyle, listening: Bool, @@ -305,27 +283,7 @@ struct DictationHUDShellView: View { shaping: Bool, reduceMotion: Bool ) -> Bool { - visual == .waveDraft && !reduceMotion && (listening || dismissing || shaping) - } - - /// Compact keeps its indicator through shaping and retracts; the recent - /// draft does the same, but a status message is a new occupant and must - /// not inherit the last session's audio or the extra 24 points of height. - static func showsRibbon( - visual: HUDVoiceVisualStyle, - listening: Bool, - dismissing: Bool, - shaping: Bool, - reduceMotion: Bool, - ribbonEnabled: Bool = true - ) -> Bool { - ribbonEnabled && showsRecentDraft( - visual: visual, - listening: listening, - dismissing: dismissing, - shaping: shaping, - reduceMotion: reduceMotion - ) + visual == .glowDraft && !reduceMotion && (listening || dismissing || shaping) } /// The room the language tag needs on each side. @@ -366,7 +324,7 @@ struct DictationHUDShellView: View { if let tag = content.languageTag { tagCapsule(tag) .padding(.leading, 12 * metrics.scale) - // Waveform + Draft overlays the tag on the whole island, so it + // Edge Glow + Draft overlays the tag on the whole island, so it // shares the draft's vertical center. Other visuals pin it below // the housing from the shell. .padding(.top, showsRecentDraft ? 0 : tagTopInset) @@ -543,11 +501,11 @@ struct DictationHUDShellView: View { HUDShellPreviewHarness(visual: .compact, shapingChoice: "Remove filler words") } -#Preview("Waveform + Draft") { - HUDShellPreviewHarness(visual: .waveDraft) +#Preview("Edge Glow + Draft") { + HUDShellPreviewHarness(visual: .glowDraft) } -#Preview("Waveform + Draft · dead mic") { - HUDShellPreviewHarness(visual: .waveDraft, micAlive: false) +#Preview("Edge Glow + Draft · dead mic") { + HUDShellPreviewHarness(visual: .glowDraft, micAlive: false) } diff --git a/Talkify/Dictation/HUD/HUDRecentDraftText.swift b/Talkify/Dictation/HUD/HUDRecentDraftText.swift index 30b1ac4..7fce105 100644 --- a/Talkify/Dictation/HUD/HUDRecentDraftText.swift +++ b/Talkify/Dictation/HUD/HUDRecentDraftText.swift @@ -26,7 +26,7 @@ struct HUDRecentDraftWindow: Equatable { /// token so a ForEach can keep surviving words in place. let visibleTokens: [VisibleToken] - /// The visible string, committed and volatile together. Waveform + Draft + /// The visible string, committed and volatile together. Edge Glow + Draft /// paints them the same: the line is a glance, not a proofread. var displayString: String { committed + volatile } @@ -153,7 +153,7 @@ private struct HUDRecentDraftLineLayout: Layout { } } -/// Waveform + Draft's live draft: one 24-point line of recent words, +/// Edge Glow + Draft's live draft: one 24-point line of recent words, /// centered in the island when it fits. Each word keeps a stable id, so /// only a new or departing token slides; a finalization rewrites in place. struct HUDRecentDraftText: View { diff --git a/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift b/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift deleted file mode 100644 index daa09b3..0000000 --- a/Talkify/Dictation/HUD/Visuals/HUDCompactChartLineView.swift +++ /dev/null @@ -1,78 +0,0 @@ -import SwiftUI - -/// The Waveform + Draft ribbon: Chart Line's conveyor in a 12-point strip -/// at the top of the draft, retuned so the line still reads at the 40% -/// HUD-size floor. The concert Chart Line's 6-point unscaled padding, −10 -/// offset, reflection, and 26-point under-glow would clip away in this strip. -struct HUDCompactChartLineView: View { - let content: DictationHUDContent - var scale: CGFloat = 1 - - @State private var samples: [Float] = [] - @State private var lastTick = Date.distantPast - @State private var tickInterval: Double = 0.022 - - private var live: Bool { - content.showsVoiceVisual && content.isAudioAlive - } - - var body: some View { - TimelineView(.animation(paused: !live)) { context in - let progress = min(1, max(0, context.date.timeIntervalSince(lastTick) / tickInterval)) - let level = content.audioLevel - let shape = SmoothLineShape( - samples: samples, - scrollProgress: (live || lastTick != .distantPast) ? progress : 0, - fill: 0.8 - ) - let hairline = max(1, 1.2 * scale) - - if content.isAudioAlive { - ZStack { - shape - .stroke( - HUDVisualTokens.chartLineSilver, - style: StrokeStyle( - lineWidth: (1.4 + 2 * level) * scale, - lineCap: .round, - lineJoin: .round - ) - ) - .blur(radius: 2 * scale) - .opacity(0.25 + 0.45 * level) - shape - .stroke( - .white.opacity(0.8 + 0.2 * level), - style: StrokeStyle(lineWidth: hairline, lineCap: .round, lineJoin: .round) - ) - } - } else { - shape - .stroke( - HUDVisualTokens.deadMicAmber.opacity(0.85), - style: StrokeStyle(lineWidth: hairline, lineCap: .round, lineJoin: .round) - ) - } - } - .padding(.horizontal, 10 * scale) - .clipped() - .onAppear { samples = Self.ribbonSamples(content.levelHistory) } - .onChange(of: content.levelHistory) { _, new in - let now = Date() - let gap = now.timeIntervalSince(lastTick) - if gap < 0.1 { - tickInterval = tickInterval * 0.8 + gap * 0.2 - } - samples = Self.ribbonSamples(new) - lastTick = now - } - .allowsHitTesting(false) - .accessibilityHidden(true) - } - - /// Square-root so quiet speech still fills the 12-point strip. Linear - /// samples sat under the concert rest lift and read as a straight line. - private static func ribbonSamples(_ samples: [Float]) -> [Float] { - samples.map { $0.squareRoot() } - } -} diff --git a/Talkify/Dictation/HUD/Visuals/HUDVisualTokens.swift b/Talkify/Dictation/HUD/Visuals/HUDVisualTokens.swift index 18afde9..9fcbdce 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDVisualTokens.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDVisualTokens.swift @@ -10,8 +10,7 @@ enum HUDVisualTokens { /// The Chart Line treatment's metallic silver: white body cooling into a /// faint blue-gray at the ends — the edge glow's palette, no saturated - /// hues. Shared so the concert band and the Waveform + Draft ribbon are - /// recognisably one treatment rather than two that drifted apart. + /// hues. Shared so every Chart Line is recognisably one treatment. static let chartLineSilver = LinearGradient( colors: [ Color(red: 0.68, green: 0.74, blue: 0.88).opacity(0.85), diff --git a/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift b/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift index 1e85680..9022cca 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift @@ -1,34 +1,44 @@ /// The voice-reactive visual shown while listening, user-selectable from /// Settings. Waveform and Edge Glow replace the draft text; Compact and -/// Waveform + Draft keep it. Reduce Motion overrides all of them with a +/// Edge Glow + Draft keep it. Reduce Motion overrides all of them with a /// quiet level meter. enum HUDVoiceVisualStyle: String, CaseIterable { /// A Metal shader waveform that fills the HUD's visual band. case waveform = "Waveform" - /// Centered recent-word live draft and Edge Glow on the silhouette, with - /// an optional slim Chart Line overlaid at the top of the same stage. No - /// five-bar indicator, particle cloud, or orb — those sit on the words. - /// The concert waveform styles stay on Waveform; this composition is one - /// designed Chart Line. - case waveDraft = "Waveform + Draft" /// An origin glow hugging the shape's edge: it blooms out of the notch /// housing on session start, breathes with the voice, and drains back /// when the session ends. Its center content is a separate pick /// (HUDGlowCenterStyle). case glow = "Edge Glow" + /// Centered recent-word live draft and Edge Glow on the silhouette. No + /// Chart Line, five-bar indicator, particle cloud, or orb. The concert + /// waveform styles stay on Waveform. + case glowDraft = "Edge Glow + Draft" /// The Dynamic Island caption look: a small voice indicator beside the /// live draft text inside the shape (HUDCompactIndicatorView); the shape /// grows with the draft. case compact = "Compact" - /// Compact and Waveform + Draft are built around the live draft, so a + /// Compact and Edge Glow + Draft are built around the live draft, so a /// placeholder would be words nobody spoke. var showsDraftWhileListening: Bool { - self == .compact || self == .waveDraft + self == .compact || self == .glowDraft } /// The beam, start ripple, shaping caption, and status-ghost accent. var usesEdgeGlow: Bool { - self == .glow || self == .waveDraft + self == .glow || self == .glowDraft + } + + /// The name stored before this visual was renamed Edge Glow + Draft. + private static let formerDraftRawValue = "Waveform + Draft" + + /// Reads a stored pick, including the name this visual used to ship under. + init?(stored: String) { + if stored == Self.formerDraftRawValue { + self = .glowDraft + return + } + self.init(rawValue: stored) } } diff --git a/Talkify/DropTranscription/HUD/DropHUDStyle.swift b/Talkify/DropTranscription/HUD/DropHUDStyle.swift index 9cfd603..9a2fe65 100644 --- a/Talkify/DropTranscription/HUD/DropHUDStyle.swift +++ b/Talkify/DropTranscription/HUD/DropHUDStyle.swift @@ -8,7 +8,7 @@ struct DropHUDStyle { /// The accent for glyphs, the peek bar, and the notice. /// /// The drop surfaces speak the voice visual's colour: Edge Glow and - /// Waveform + Draft lend the palette — the same hue the status ghost takes + /// Edge Glow + Draft lend the palette — the same hue the status ghost takes /// during those sessions, so one palette means one colour everywhere — and /// every other visual keeps the Talkify blue. Settings chrome is unaffected /// either way; the palette colours the HUD, never the app (CONTEXT.md). diff --git a/Talkify/Settings/Sections/AppearanceSettingsView.swift b/Talkify/Settings/Sections/AppearanceSettingsView.swift index be8da37..8f7d24d 100644 --- a/Talkify/Settings/Sections/AppearanceSettingsView.swift +++ b/Talkify/Settings/Sections/AppearanceSettingsView.swift @@ -10,9 +10,9 @@ struct AppearanceSettingsView: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion /// Which conditional rows the selected visual exposes; hidden values - /// stay persisted (CONTEXT.md). Waveform + Draft is a designed Chart - /// Line, so it does not offer the concert style picker. It takes the - /// glow palette (beam, shaping caption, status ghost) but not a center. + /// stay persisted (CONTEXT.md). Edge Glow + Draft takes the glow palette + /// (beam, shaping caption, status ghost) but not a center or the concert + /// waveform styles. static func showsWaveformOptions(for visual: HUDVoiceVisualStyle) -> Bool { visual == .waveform } @@ -25,18 +25,14 @@ struct AppearanceSettingsView: View { visual == .glow } - static func showsWaveDraftRibbon(for visual: HUDVoiceVisualStyle) -> Bool { - visual == .waveDraft - } - - /// Waveform + Draft uses a recent-word line instead of the global long + /// Edge Glow + Draft uses a recent-word line instead of the global long /// draft behaviors. Reduce Motion restores the plain band, where the /// existing pick still applies. static func showsLongDraftBehavior( for visual: HUDVoiceVisualStyle, reduceMotion: Bool ) -> Bool { - !(visual == .waveDraft && !reduceMotion) + !(visual == .glowDraft && !reduceMotion) } var body: some View { @@ -81,18 +77,6 @@ struct AppearanceSettingsView: View { selection: $settings.glowCenter ) } - - if Self.showsWaveDraftRibbon(for: settings.voiceVisual) { - SettingsRow( - title: "Chart Line ribbon", - description: "The slim waveform under the housing. Off leaves " - + "the live draft and the glow." - ) { - Toggle("Chart Line ribbon", isOn: $settings.waveDraftShowsRibbon) - .labelsHidden() - .toggleStyle(.switch) - } - } } SettingsCard(title: "Motion and layout") { diff --git a/TalkifyTests/AppSettingsTests.swift b/TalkifyTests/AppSettingsTests.swift index edcc309..b0ad27d 100644 --- a/TalkifyTests/AppSettingsTests.swift +++ b/TalkifyTests/AppSettingsTests.swift @@ -20,7 +20,6 @@ struct AppSettingsTests { #expect(settings.waveformStyle == .chartLine) #expect(settings.revealStyle == .slide) #expect(settings.longDraftStyle == .growDown) - #expect(settings.waveDraftShowsRibbon) // Existing users must not be resized by an upgrade. #expect(settings.hudScale == Double(HUDMetrics.maximumScale)) #expect(settings.readAloudVoiceID.isEmpty) @@ -38,7 +37,6 @@ struct AppSettingsTests { settings.waveformStyle = .dots settings.revealStyle = .bloom settings.longDraftStyle = .shrinkToFit - settings.waveDraftShowsRibbon = false settings.hudScale = 0.75 settings.readAloudVoiceID = "com.apple.voice.premium.en-US.Zoe" let f5Binding = KeyBinding( @@ -60,7 +58,6 @@ struct AppSettingsTests { #expect(reloaded.waveformStyle == .dots) #expect(reloaded.revealStyle == .bloom) #expect(reloaded.longDraftStyle == .shrinkToFit) - #expect(!reloaded.waveDraftShowsRibbon) #expect(reloaded.hudScale == 0.75) #expect(reloaded.readAloudVoiceID == "com.apple.voice.premium.en-US.Zoe") #expect(reloaded.dictationTriggerBinding == rightCommandTrigger) @@ -83,16 +80,21 @@ struct AppSettingsTests { #expect(defaults.object(forKey: "dictationSoundsEnabled") as? Bool == false) #expect(defaults.double(forKey: "dictationSoundVolume") == 0.2) #expect(defaults.string(forKey: "hudVoiceVisual") == "Edge Glow") - settings.voiceVisual = .waveDraft - #expect(defaults.string(forKey: "hudVoiceVisual") == "Waveform + Draft") + settings.voiceVisual = .glowDraft + #expect(defaults.string(forKey: "hudVoiceVisual") == "Edge Glow + Draft") #expect(defaults.string(forKey: "hudWaveformStyle") == "Silver") #expect(defaults.string(forKey: "hudRevealStyle") == "Drift") #expect(defaults.string(forKey: "hudLongDraftStyle") == "Tail Only") - settings.waveDraftShowsRibbon = false - #expect(defaults.object(forKey: "hudWaveDraftShowsRibbon") as? Bool == false) #expect(defaults.string(forKey: "readAloudVoice") == "com.apple.voice.enhanced.en-GB.Jamie") } + @Test func aFormerWaveformPlusDraftPickBecomesEdgeGlowPlusDraft() { + let defaults = freshDefaults() + defaults.set("Waveform + Draft", forKey: "hudVoiceVisual") + let settings = AppSettings(defaults: defaults) + #expect(settings.voiceVisual == .glowDraft) + } + @Test func keyBindingsPersistUnderTheirHistoricalKeys() { let defaults = freshDefaults() let settings = AppSettings(defaults: defaults) @@ -333,7 +335,6 @@ struct AppSettingsTests { settings.waveformStyle = .dots settings.revealStyle = .bloom settings.longDraftStyle = .tailOnly - settings.waveDraftShowsRibbon = false settings.glowPalette = .aurora settings.glowCenter = .siriOrb settings.hudScale = 0.6 @@ -343,7 +344,6 @@ struct AppSettingsTests { #expect(snapshot.waveformStyle == .chartLine) #expect(snapshot.revealStyle == .slide) #expect(snapshot.longDraftStyle == .growDown) - #expect(snapshot.waveDraftShowsRibbon) #expect(snapshot.glowPalette == .spectrum) #expect(snapshot.glowCenter == .particles) #expect(snapshot.hudMetrics == .standard) diff --git a/TalkifyTests/HUDMetricsTests.swift b/TalkifyTests/HUDMetricsTests.swift index c9b01ba..d28d8b0 100644 --- a/TalkifyTests/HUDMetricsTests.swift +++ b/TalkifyTests/HUDMetricsTests.swift @@ -7,8 +7,7 @@ struct HUDMetricsTests { #expect(HUDMetrics.standard.scale == 1) #expect(HUDMetrics.standard.contentWidth == 540) #expect(HUDMetrics.standard.waveBandHeight == 64) - #expect(HUDMetrics.standard.ribbonBandHeight == 12) - #expect(HUDMetrics.standard.waveDraftStageHeight == 40) + #expect(HUDMetrics.standard.glowDraftStageHeight == 40) } @Test func everyDimensionScalesTogether() { @@ -20,8 +19,7 @@ struct HUDMetricsTests { #expect(half.maxTextBandHeight == full.maxTextBandHeight * 0.8) #expect(half.visualBandHeight == full.visualBandHeight * 0.8) #expect(half.waveBandHeight == full.waveBandHeight * 0.8) - #expect(half.ribbonBandHeight == full.ribbonBandHeight * 0.8) - #expect(half.waveDraftStageHeight == full.waveDraftStageHeight * 0.8) + #expect(half.glowDraftStageHeight == full.glowDraftStageHeight * 0.8) #expect(half.bottomCornerRadius == full.bottomCornerRadius * 0.8) } @@ -34,12 +32,12 @@ struct HUDMetricsTests { #expect(HUDMetrics(scale: 0).scale == HUDMetrics.minimumScale) } - /// Compact and Waveform + Draft are built around the live draft, so they + /// Compact and Edge Glow + Draft are built around the live draft, so they /// give up the smallest sizes. Reduce Motion restores the draft for every /// visual and takes those sizes away from all of them. @Test func draftVisualsAndReduceMotionGiveUpTheSmallestSizes() { #expect(HUDMetrics.minimumScale(for: .compact, reduceMotion: false) == 0.4) - #expect(HUDMetrics.minimumScale(for: .waveDraft, reduceMotion: false) == 0.4) + #expect(HUDMetrics.minimumScale(for: .glowDraft, reduceMotion: false) == 0.4) #expect(HUDMetrics.minimumScale(for: .waveform, reduceMotion: false) == 0.2) #expect(HUDMetrics.minimumScale(for: .glow, reduceMotion: false) == 0.2) #expect(HUDMetrics.minimumScale(for: .waveform, reduceMotion: true) == 0.4) @@ -112,44 +110,32 @@ struct HUDShellMetricsTests { ).scale } - /// Listening, retracting, and shaping keep the ribbon; a status message - /// does not. Compact never has one. - @Test func theRibbonFollowsOccupancy() { - #expect(DictationHUDShellView.showsRibbon( - visual: .waveDraft, listening: true, dismissing: false, shaping: false, - reduceMotion: false)) - #expect(DictationHUDShellView.showsRibbon( - visual: .waveDraft, listening: false, dismissing: true, shaping: false, - reduceMotion: false)) - #expect(DictationHUDShellView.showsRibbon( - visual: .waveDraft, listening: false, dismissing: false, shaping: true, - reduceMotion: false)) - #expect(!DictationHUDShellView.showsRibbon( - visual: .waveDraft, listening: false, dismissing: false, shaping: false, - reduceMotion: false)) - #expect(!DictationHUDShellView.showsRibbon( - visual: .waveDraft, listening: true, dismissing: false, shaping: false, - reduceMotion: true)) - #expect(!DictationHUDShellView.showsRibbon( - visual: .compact, listening: true, dismissing: false, shaping: false, + /// Listening, retracting, and shaping keep the recent draft; a status + /// message does not. Compact never has one. + @Test func theRecentDraftFollowsOccupancy() { + #expect(DictationHUDShellView.showsRecentDraft( + visual: .glowDraft, listening: true, dismissing: false, shaping: false, reduceMotion: false)) - #expect(!DictationHUDShellView.showsRibbon( - visual: .waveDraft, listening: true, dismissing: false, shaping: false, - reduceMotion: false, ribbonEnabled: false)) #expect(DictationHUDShellView.showsRecentDraft( - visual: .waveDraft, listening: true, dismissing: false, shaping: false, + visual: .glowDraft, listening: false, dismissing: true, shaping: false, reduceMotion: false)) #expect(DictationHUDShellView.showsRecentDraft( - visual: .waveDraft, listening: true, dismissing: false, shaping: false, + visual: .glowDraft, listening: false, dismissing: false, shaping: true, + reduceMotion: false)) + #expect(!DictationHUDShellView.showsRecentDraft( + visual: .glowDraft, listening: false, dismissing: false, shaping: false, reduceMotion: false)) #expect(!DictationHUDShellView.showsRecentDraft( - visual: .waveDraft, listening: false, dismissing: false, shaping: false, + visual: .glowDraft, listening: true, dismissing: false, shaping: false, + reduceMotion: true)) + #expect(!DictationHUDShellView.showsRecentDraft( + visual: .compact, listening: true, dismissing: false, shaping: false, reduceMotion: false)) } @Test func aDraftLayoutIsHeldAtTheReadableFloorAndOthersAreNot() { #expect(scale(picked: 0.2, visual: .compact) == HUDMetrics.minimumReadableScale) - #expect(scale(picked: 0.2, visual: .waveDraft) == HUDMetrics.minimumReadableScale) + #expect(scale(picked: 0.2, visual: .glowDraft) == HUDMetrics.minimumReadableScale) #expect(scale(picked: 0.2, visual: .waveform) == HUDMetrics.minimumScale) #expect(scale(picked: 0.7, visual: .compact) == 0.7) } diff --git a/TalkifyTests/HUDNotchGeometryTests.swift b/TalkifyTests/HUDNotchGeometryTests.swift index 9e80ce2..9df9470 100644 --- a/TalkifyTests/HUDNotchGeometryTests.swift +++ b/TalkifyTests/HUDNotchGeometryTests.swift @@ -142,27 +142,15 @@ struct HUDNotchGeometryTests { #expect(tallest <= window.height - HUDNotchGeometry.shadowPadding) } - /// Waveform + Draft has no dedicated visual band: the ribbon lives in the - /// text band. Grow Down plus shaping still fits; a concert waveform on top - /// of the draft would not. - @Test func waveDraftMaximumStackFitsTheFixedWindow() { + /// Edge Glow + Draft has no dedicated visual band: the hanging stage + /// replaces the ordinary text band. Housing plus that stage plus shaping + /// still fits; a concert waveform on top of the draft would not. + @Test func glowDraftMaximumStackFitsTheFixedWindow() { let window = HUDNotchGeometry.windowFrame(for: notched, clearsMenuBar: false) - let content = HUDNotchGeometry.contentSize( - for: notched, - metrics: .standard, - visualBandHeight: HUDMetrics.standard.visualBandHeight, - includesTextBand: true, - shapingBandHeight: HUDMetrics.standard.shapingBandHeight - ) - let growDown = 32 - + HUDMetrics.standard.ribbonBandHeight - + HUDMetrics.standard.maxTextBandHeight + let glowDraft = 32 + + HUDMetrics.standard.glowDraftStageHeight + HUDMetrics.standard.shapingBandHeight - #expect(content.height == 32 - + HUDMetrics.standard.visualBandHeight - + HUDMetrics.standard.textBandHeight - + HUDMetrics.standard.shapingBandHeight) - #expect(growDown <= window.height - HUDNotchGeometry.shadowPadding) + #expect(glowDraft <= window.height - HUDNotchGeometry.shadowPadding) let concertPlusDraft = 32 + HUDMetrics.standard.waveBandHeight + HUDMetrics.standard.maxTextBandHeight diff --git a/TalkifyTests/HUDPlaceholderTests.swift b/TalkifyTests/HUDPlaceholderTests.swift index 3542bbd..1d0a869 100644 --- a/TalkifyTests/HUDPlaceholderTests.swift +++ b/TalkifyTests/HUDPlaceholderTests.swift @@ -3,7 +3,7 @@ import Testing @testable import Talkify -/// Compact and Waveform + Draft are built around the live draft, so they +/// Compact and Edge Glow + Draft are built around the live draft, so they /// open with nothing. Three separate paths write the placeholder — opening, /// latching, and coming back from a model download — and fixing only the /// first left "Listening (latched)" still appearing. @@ -20,8 +20,8 @@ struct HUDPlaceholderTests { store.sessionSettings } - private func ribbon(_ stage: HUDStage, visual: HUDVoiceVisualStyle) -> Bool { - DictationHUDShellView.showsRibbon( + private func recentDraft(_ stage: HUDStage, visual: HUDVoiceVisualStyle) -> Bool { + DictationHUDShellView.showsRecentDraft( visual: visual, listening: stage.dictationContent.showsVoiceVisual, dismissing: stage.dictationContent.isDismissing, @@ -30,7 +30,7 @@ struct HUDPlaceholderTests { ) } - @Test(arguments: [HUDVoiceVisualStyle.compact, .waveDraft]) + @Test(arguments: [HUDVoiceVisualStyle.compact, .glowDraft]) func aDraftVisualOpensWithNoText(visual: HUDVoiceVisualStyle) { let store = AppSettings.previewStore() store.voiceVisual = visual @@ -48,7 +48,7 @@ struct HUDPlaceholderTests { /// A latched draft visual still says nothing, which is the case that was /// reported after the opening text was already handled. - @Test(arguments: [HUDVoiceVisualStyle.compact, .waveDraft]) + @Test(arguments: [HUDVoiceVisualStyle.compact, .glowDraft]) func aDraftVisualLatchesWithNoText(visual: HUDVoiceVisualStyle) { let store = AppSettings.previewStore() store.voiceVisual = visual @@ -117,7 +117,7 @@ struct HUDPlaceholderTests { @Test func aVolatileGuessShowsBeforeItCommits() { let store = AppSettings.previewStore() - store.voiceVisual = .waveDraft + store.voiceVisual = .glowDraft let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store) hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store)) @@ -127,7 +127,7 @@ struct HUDPlaceholderTests { @Test func aStatusMessageDoesNotKeepThePreviousGuess() { let store = AppSettings.previewStore() - store.voiceVisual = .waveDraft + store.voiceVisual = .glowDraft let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store) hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store)) @@ -137,27 +137,28 @@ struct HUDPlaceholderTests { } /// hide() pins the layout for the retract, then an insert failure claims - /// the shape for a message. That message is a new occupant: the ribbon - /// stays through shaping and the retract, and leaves with the status line. - @Test func aStatusMessageDoesNotKeepTheRibbon() { + /// the shape for a message. That message is a new occupant: the recent + /// draft stays through shaping and the retract, and leaves with the + /// status line. + @Test func aStatusMessageDoesNotKeepTheRecentDraft() { let store = AppSettings.previewStore() - store.voiceVisual = .waveDraft + store.voiceVisual = .glowDraft let stage = HUDStage(settings: store) let hud = DictationHUDController(stage: stage, settings: store) hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store)) hud.showLiveText("the words it is rewriting") hud.showShaping(with: "Tighten grammar") - #expect(ribbon(stage, visual: .waveDraft)) + #expect(recentDraft(stage, visual: .glowDraft)) hud.hide() #expect(stage.dictationContent.isDismissing) - #expect(ribbon(stage, visual: .waveDraft)) + #expect(recentDraft(stage, visual: .glowDraft)) hud.showMessage("Couldn't insert text") #expect(!stage.dictationContent.isDismissing) #expect(stage.dictationContent.shapingName == nil) - #expect(!ribbon(stage, visual: .waveDraft)) + #expect(!recentDraft(stage, visual: .glowDraft)) #expect(hud.textForTesting == "Couldn't insert text") } } diff --git a/TalkifyTests/HUDRenderTests.swift b/TalkifyTests/HUDRenderTests.swift index befde5d..74575e4 100644 --- a/TalkifyTests/HUDRenderTests.swift +++ b/TalkifyTests/HUDRenderTests.swift @@ -232,39 +232,34 @@ struct HUDRenderTests { _ = try render(card, named: "12-drop-preview-card") } - /// Ribbon on and off share the hanging stage; the Chart Line is only - /// pixels on top of it. The PNG canvas is the fixed host, so this is a - /// look-at fixture rather than a size assertion. - @Test func waveDraftRendersWithAndWithoutTheRibbon() throws { - for ribbon in [true, false] { - let store = AppSettings.previewStore() - store.voiceVisual = .waveDraft - store.waveDraftShowsRibbon = ribbon - let content = DictationHUDContent() - content.isRevealed = true - content.showsVoiceVisual = true - content.isAudioAlive = true - content.text = "one two three four five six seven eight" - content.languageTag = "EN" + /// The PNG canvas is the fixed host, so this is a look-at fixture + /// rather than a size assertion. + @Test func glowDraftRendersTheRecentWordLine() throws { + let store = AppSettings.previewStore() + store.voiceVisual = .glowDraft + let content = DictationHUDContent() + content.isRevealed = true + content.showsVoiceVisual = true + content.isAudioAlive = true + content.text = "one two three four five six seven eight" + content.languageTag = "EN" - _ = try render( - DictationHUDShellView( - screen: notchedScreen, - settings: store.sessionSettings, - content: content - ), - named: ribbon ? "14-wave-draft-ribbon" : "15-wave-draft-text" - ) - } + _ = try render( + DictationHUDShellView( + screen: notchedScreen, + settings: store.sessionSettings, + content: content + ), + named: "14-glow-draft-text" + ) } /// Ordinary eight-word drafts are wider than the island. This is a /// look-at fixture for one overflow policy: the newest words stay /// readable, the oldest clip, and no token ellipsizes on its own. - @Test func waveDraftRendersALongEightWordLine() throws { + @Test func glowDraftRendersALongEightWordLine() throws { let store = AppSettings.previewStore() - store.voiceVisual = .waveDraft - store.waveDraftShowsRibbon = false + store.voiceVisual = .glowDraft let content = DictationHUDContent() content.isRevealed = true content.showsVoiceVisual = true @@ -278,7 +273,7 @@ struct HUDRenderTests { settings: store.sessionSettings, content: content ), - named: "16-wave-draft-long-line" + named: "16-glow-draft-long-line" ) } diff --git a/TalkifyTests/SettingsWindowTests.swift b/TalkifyTests/SettingsWindowTests.swift index 8143ee6..7cdf290 100644 --- a/TalkifyTests/SettingsWindowTests.swift +++ b/TalkifyTests/SettingsWindowTests.swift @@ -55,23 +55,20 @@ struct SettingsWindowTests { #expect(AppearanceSettingsView.showsWaveformOptions(for: .waveform)) #expect(!AppearanceSettingsView.showsWaveformOptions(for: .glow)) #expect(!AppearanceSettingsView.showsWaveformOptions(for: .compact)) - #expect(!AppearanceSettingsView.showsWaveformOptions(for: .waveDraft)) + #expect(!AppearanceSettingsView.showsWaveformOptions(for: .glowDraft)) #expect(AppearanceSettingsView.showsGlowPalette(for: .glow)) - #expect(AppearanceSettingsView.showsGlowPalette(for: .waveDraft)) + #expect(AppearanceSettingsView.showsGlowPalette(for: .glowDraft)) #expect(!AppearanceSettingsView.showsGlowPalette(for: .waveform)) #expect(!AppearanceSettingsView.showsGlowPalette(for: .compact)) #expect(AppearanceSettingsView.showsGlowCenter(for: .glow)) - #expect(!AppearanceSettingsView.showsGlowCenter(for: .waveDraft)) + #expect(!AppearanceSettingsView.showsGlowCenter(for: .glowDraft)) #expect(!AppearanceSettingsView.showsGlowCenter(for: .waveform)) #expect(!AppearanceSettingsView.showsLongDraftBehavior( - for: .waveDraft, reduceMotion: false)) + for: .glowDraft, reduceMotion: false)) #expect(AppearanceSettingsView.showsLongDraftBehavior( - for: .waveDraft, reduceMotion: true)) + for: .glowDraft, reduceMotion: true)) #expect(AppearanceSettingsView.showsLongDraftBehavior( for: .compact, reduceMotion: false)) - #expect(AppearanceSettingsView.showsWaveDraftRibbon(for: .waveDraft)) - #expect(!AppearanceSettingsView.showsWaveDraftRibbon(for: .waveform)) - #expect(!AppearanceSettingsView.showsWaveDraftRibbon(for: .glow)) } @Test func releaseMetadataExcludesUnlicensedOptions() { diff --git a/docs/design/FEATURE.md b/docs/design/FEATURE.md index 92676db..6583fbd 100644 --- a/docs/design/FEATURE.md +++ b/docs/design/FEATURE.md @@ -49,7 +49,7 @@ Open shows a media type glyph, the file name, and nothing else. The edge carries a dashed accent stroke, so it reads as live and receptive. The accent is not fixed. It follows the voice visual: with Edge Glow or -Waveform + Draft selected the target wears that palette — the full gradient +Edge Glow + Draft selected the target wears that palette — the full gradient on its edge, where there is length enough to show a palette off, and the palette's one representative hue on the glyph, the peek bar and the closing line. Sunset makes an orange target; Aurora a green one. Every other visual diff --git a/docs/design/hud-anatomy.md b/docs/design/hud-anatomy.md index aa5e914..d612a7d 100644 --- a/docs/design/hud-anatomy.md +++ b/docs/design/hud-anatomy.md @@ -16,7 +16,7 @@ as the notch itself stretching downward. ``` screen top edge ───────┬─────────────────┬─────── - │▓▓▓ housing ▓▓▓ │ ← camera lives here; flanks can carry Waveform + Draft + │▓▓▓ housing ▓▓▓ │ ← camera lives here; flanks can carry Edge Glow + Draft ╭──────┴─────────────────┴──────╮ │ │ ← visual band (voice visual) │ │ @@ -37,8 +37,7 @@ detached tabs. | Text band height | `36 * scale` min, `120 * scale` max | yes | | Visual band, Reduce Motion | `24 * scale` | yes | | Visual band, waveform / glow | `64 * scale` | yes | -| Waveform + Draft hanging stage | `40 * scale` | yes | -| Waveform + Draft ribbon | `12 * scale`, inside the hanging stage | yes | +| Edge Glow + Draft hanging stage | `40 * scale` | yes | | Bottom corner radius | `20 * scale` | yes | | Housing, measured notch | whatever the display reports | **no** | | Housing, simulated | `185 × 32` | **no** | @@ -57,7 +56,7 @@ bezel curve. **Housing band.** Always present. Its height is the notch's height. The camera sits in the middle of this strip, so Compact, Waveform, Edge Glow, -and status messages leave it empty. Waveform + Draft is the exception: its +and status messages leave it empty. Edge Glow + Draft is the exception: its recent-word line is an overlay centered on the housing-plus-stage silhouette, because the flanks of that strip are still visible beside the camera. The camera itself stays empty. @@ -65,25 +64,25 @@ camera. The camera itself stays empty. **Visual band.** Holds the voice visual. Its height depends on which visual is selected: `64 * scale` for Waveform, `64 * scale` (empty, used as a stage for edge-hugging light) for Edge Glow, `24 * scale` for the Reduce Motion -level meter, and zero for Compact and Waveform + Draft, whose indicator and -Chart Line ribbon live inside the text band instead. +level meter, and zero for Compact and Edge Glow + Draft, whose indicator +and hanging stage live inside the text band instead. -**Text band.** Holds live draft text. Present for Compact and Waveform + +**Text band.** Holds live draft text. Present for Compact and Edge Glow + Draft, absent for Waveform and Edge Glow while listening, and always present under Reduce Motion. Draft text is 15pt medium white and centered, except -Compact (leading, beside the five-bar indicator) and Waveform + Draft (a +Compact (leading, beside the five-bar indicator) and Edge Glow + Draft (a `24 * scale` recent-word line centered in the island, overlaying housing plus a `40 * scale` hanging stage). Compact keeps the three long-draft behaviors the user picks between: single line with head truncation, wrap and grow downward capped at four lines, or single line that shrinks to -fit. Waveform + Draft does not: eight tokens on one line, and a line that +fit. Edge Glow + Draft does not: eight tokens on one line, and a line that does not fit clips the oldest words instead of ellipsizing each one. **Language tag.** A small capsule shown only when a second dictation language is configured. It hangs off the shell as an overlay rather than sitting in the layout, so it never changes the window's size. Compact, Waveform, Edge Glow, and status messages pin it to the top leading corner -below the housing. Waveform + Draft overlays it on the leading edge of the +below the housing. Edge Glow + Draft overlays it on the leading edge of the island, vertically centered with the draft. ## Hard rules that keep being violated @@ -99,7 +98,7 @@ itself inside the same fixed window. overshoot lifts the shape off the screen edge and opens a visible gap above it. Scale anchored at `.top` cannot. -**Nothing is drawn in the housing band**, except Waveform + Draft's +**Nothing is drawn in the housing band**, except Edge Glow + Draft's recent-word overlay, which is centered on the housing-plus-stage silhouette so the line sits in the visible flanks rather than only under the camera. Status messages, Compact, Waveform, and Edge Glow still leave diff --git a/docs/design/voice-visuals.md b/docs/design/voice-visuals.md index 50e1270..551657a 100644 --- a/docs/design/voice-visuals.md +++ b/docs/design/voice-visuals.md @@ -30,18 +30,16 @@ after the iOS Dynamic Island caption look. Bars are 2.5pt wide capsules, 2.5pt apart, resting at 3pt and reaching about 14pt. The shape grows with the text. -**Waveform + Draft** — the last eight word tokens on one `24 * scale` +**Edge Glow + Draft** — the last eight word tokens on one `24 * scale` line centered in the island (housing plus a `40 * scale` stage), with -Edge Glow on the silhouette. A `12 * scale` Chart Line ribbon can sit -under the housing; it is on by default and can be turned off without -shrinking the box. No five-bar indicator, particle cloud, or orb. -The concert waveform styles stay on Waveform; this pick is one designed -Chart Line. Glow palette colours the beam, the shaping caption, and the -status ghost. Putting the draft on top of the 64-point waveform was -prototyped and removed; this is the other direction: the ribbon shares the -draft's band so the words sit in the same center either way. New and -departing tokens slide; a finalization does not fade the line. A line -that does not fit keeps the newest words readable and clips the oldest. +Edge Glow on the silhouette. No Chart Line ribbon, five-bar indicator, +particle cloud, or orb. The concert waveform styles stay on Waveform. +Glow palette colours the beam, the shaping caption, and the status ghost. +Putting the draft on top of the 64-point waveform was prototyped and +removed; this is the other direction: the words sit in the hanging stage +under the glow. New and departing tokens slide; a finalization does not +fade the line. A line that does not fit keeps the newest words readable +and clips the oldest. Under Reduce Motion all four are replaced by a quiet level meter in the slim `24 * scale` band, and the draft text always shows. diff --git a/docs/settings-ui-spec.md b/docs/settings-ui-spec.md index 31eb5e6..6dcfbc4 100644 --- a/docs/settings-ui-spec.md +++ b/docs/settings-ui-spec.md @@ -47,9 +47,9 @@ The content pane must update in place when a navigation item is selected. The se ### Appearance -- Voice visual: Waveform, Waveform + Draft, Edge Glow, or Compact. +- Voice visual: Waveform, Edge Glow, Edge Glow + Draft, or Compact. - Waveform style, shown only for Waveform. -- Glow palette, shown for Edge Glow and Waveform + Draft. +- Glow palette, shown for Edge Glow and Edge Glow + Draft. - Glow center, shown only for Edge Glow. - Reveal style. - Long draft behavior. From 6b43398a32e1c26cc5f06b60ceb10790669a216d Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Sat, 5 Sep 2026 12:56:05 -0700 Subject: [PATCH 11/11] Drop the old Waveform + Draft stored-name alias Unknown picks already fall back to Waveform. This visual only exists as Edge Glow + Draft. --- Talkify/App/AppSettings.swift | 3 +-- .../Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift | 12 ------------ TalkifyTests/AppSettingsTests.swift | 7 ------- 3 files changed, 1 insertion(+), 21 deletions(-) diff --git a/Talkify/App/AppSettings.swift b/Talkify/App/AppSettings.swift index ca4579d..4139e61 100644 --- a/Talkify/App/AppSettings.swift +++ b/Talkify/App/AppSettings.swift @@ -310,8 +310,7 @@ final class AppSettings { promptShapingPromptID = defaults.string(forKey: Keys.promptShapingPrompt) ?? ShapingPrompt.defaults[0].id shapingPrompts = Self.storedShapingPrompts(in: defaults) ?? ShapingPrompt.defaults - voiceVisual = defaults.string(forKey: Keys.voiceVisual).flatMap(HUDVoiceVisualStyle.init(stored:)) - ?? .waveform + voiceVisual = Self.stored(in: defaults, key: Keys.voiceVisual) ?? .waveform waveformStyle = Self.stored(in: defaults, key: Keys.waveformStyle) ?? .chartLine revealStyle = Self.stored(in: defaults, key: Keys.revealStyle) ?? .slide longDraftStyle = Self.stored(in: defaults, key: Keys.longDraftStyle) ?? .growDown diff --git a/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift b/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift index 9022cca..33e94f7 100644 --- a/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift +++ b/Talkify/Dictation/HUD/Visuals/HUDVoiceVisualStyle.swift @@ -29,16 +29,4 @@ enum HUDVoiceVisualStyle: String, CaseIterable { var usesEdgeGlow: Bool { self == .glow || self == .glowDraft } - - /// The name stored before this visual was renamed Edge Glow + Draft. - private static let formerDraftRawValue = "Waveform + Draft" - - /// Reads a stored pick, including the name this visual used to ship under. - init?(stored: String) { - if stored == Self.formerDraftRawValue { - self = .glowDraft - return - } - self.init(rawValue: stored) - } } diff --git a/TalkifyTests/AppSettingsTests.swift b/TalkifyTests/AppSettingsTests.swift index b0ad27d..52d636c 100644 --- a/TalkifyTests/AppSettingsTests.swift +++ b/TalkifyTests/AppSettingsTests.swift @@ -88,13 +88,6 @@ struct AppSettingsTests { #expect(defaults.string(forKey: "readAloudVoice") == "com.apple.voice.enhanced.en-GB.Jamie") } - @Test func aFormerWaveformPlusDraftPickBecomesEdgeGlowPlusDraft() { - let defaults = freshDefaults() - defaults.set("Waveform + Draft", forKey: "hudVoiceVisual") - let settings = AppSettings(defaults: defaults) - #expect(settings.voiceVisual == .glowDraft) - } - @Test func keyBindingsPersistUnderTheirHistoricalKeys() { let defaults = freshDefaults() let settings = AppSettings(defaults: defaults)