From afe37ad5f4675936e9fca6d6aa75e1d5cab7ee83 Mon Sep 17 00:00:00 2001 From: Shane Maynard Date: Sat, 12 Sep 2026 08:27:48 -0400 Subject: [PATCH] Fix reading-safety and accessibility majors from review - Transcript: drag suspends auto-follow; long-press selection suspends following; Latest is a trailing outlined capsule that re-enables follow - Lightwell: pulse via isolated TimelineView (2.4s triangle wave), pauses when offscreen; Reduce Motion uses static geometry (no level resizing) - Increase Contrast: solid Rosewood lightwell fill, 2pt Rosewood selected outlines on mode buttons - Haptics: success only on entering ready, error only on entering failure --- .../Conversation/ConversationView.swift | 16 ++- .../Features/Conversation/LightwellView.swift | 107 +++++++++++------- .../Conversation/TranscriptListView.swift | 53 +++++++-- 3 files changed, 121 insertions(+), 55 deletions(-) diff --git a/Sources/Features/Conversation/ConversationView.swift b/Sources/Features/Conversation/ConversationView.swift index d028d4d..ed6431f 100644 --- a/Sources/Features/Conversation/ConversationView.swift +++ b/Sources/Features/Conversation/ConversationView.swift @@ -144,6 +144,7 @@ private struct ConversationContent: View { @FocusState private var composerFocused: Bool @Environment(\.dynamicTypeSize) private var typeSize @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.colorSchemeContrast) private var contrast init(settings: SettingsStore, session: VoiceSession) { self.settings = settings @@ -171,8 +172,14 @@ private struct ConversationContent: View { .onChange(of: session.state) { _, state in if state == .ready && settings.textMode { composerFocused = true } } - .sensoryFeedback(.success, trigger: session.state == .ready ? true : false) { _, _ in true } - .sensoryFeedback(.error, trigger: session.state.isFailed) { _, _ in true } + .sensoryFeedback(.success, trigger: session.state == .ready) { old, new in + // Success only on the transition INTO readiness, not out. + !old && new + } + .sensoryFeedback(.error, trigger: session.state.isFailed) { old, new in + // Error only on entering failure, not leaving it. + !old && new + } } // MARK: Welcome (pre-connection, no history) @@ -378,11 +385,12 @@ private struct ConversationContent: View { .font(NearlightFont.control(typeSize)) .foregroundStyle(selected ? Nearlight.rosewood : Nearlight.ink) .frame(maxWidth: .infinity, minHeight: 52) - .background(selected ? Nearlight.selectedWash : Nearlight.surface, + .background(selected && contrast == .increased ? Nearlight.surface : selected ? Nearlight.selectedWash : Nearlight.surface, in: RoundedRectangle(cornerRadius: Nearlight.radiusControl)) .overlay( RoundedRectangle(cornerRadius: Nearlight.radiusControl) - .strokeBorder(selected ? Nearlight.rosewood.opacity(0.4) : Nearlight.quiet, lineWidth: 1) + .strokeBorder(selected && contrast == .increased ? Nearlight.rosewood : selected ? Nearlight.rosewood.opacity(0.4) : Nearlight.quiet, + lineWidth: selected && contrast == .increased ? 2 : 1) ) } .accessibilityIdentifier(id) diff --git a/Sources/Features/Conversation/LightwellView.swift b/Sources/Features/Conversation/LightwellView.swift index 3db8142..6d1d45b 100644 --- a/Sources/Features/Conversation/LightwellView.swift +++ b/Sources/Features/Conversation/LightwellView.swift @@ -26,9 +26,6 @@ struct LightwellView: View { /// Compact (24×36) in the conversation status strip; full (64×96) on welcome. var compact: Bool = false @Environment(\.accessibilityReduceMotion) private var reduceMotion - /// Pending-state pulse driver, 0 → 1 → 0 over the 2.4s cycle. - @State private var pulse: Double = 0 - /// Frame dimensions never animate; inner dimensions multiply by 0.375 /// in the compact frame. private var frameSize: CGSize { @@ -41,55 +38,87 @@ struct LightwellView: View { : base } + @Environment(\.colorSchemeContrast) private var contrast + var body: some View { - let inner = innerSize - ZStack { - Capsule() - .fill(Nearlight.surface) - Capsule() - .fill( - LinearGradient( - stops: [ - .init(color: Nearlight.rosewood.opacity(0.35), location: 0), - .init(color: Nearlight.rosewood.opacity(1), location: 0.5), - .init(color: Nearlight.rosewood.opacity(0.55), location: 1), - ], - startPoint: .top, endPoint: .bottom - ) - ) - .frame(width: inner.width, height: inner.height) - .opacity(overallOpacity) - .animation(reduceMotion ? nil : Nearlight.quick, value: overallOpacity) - .animation(reduceMotion ? nil : Nearlight.settle, value: innerSize) - } - .frame(width: frameSize.width, height: frameSize.height) - .overlay( - Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1) - ) - .accessibilityHidden(true) - .task(id: pulseActive) { - guard pulseActive else { - pulse = 0 - return - } - while !Task.isCancelled { - withAnimation(reduceMotion ? nil : Nearlight.cycle) { pulse = pulse == 0 ? 1 : 0 } - try? await Task.sleep(for: .seconds(2.4)) + Group { + if pulseWanted { + // Isolated TimelineView: ticks only while pending states are + // showing; pauses when offscreen/inactive automatically. + TimelineView(.periodic(from: .now, by: 0.05)) { context in + lightwell(pulse: pulseValue(at: context.date)) + } + } else { + lightwell(pulse: 0) } } } - /// Whether the pulse loop should run for this phase. - private var pulseActive: Bool { + private func lightwell(pulse: Double) -> some View { + let inner = innerSize + return ZStack { + Capsule() + .fill(Nearlight.surface) + Capsule() + .fill(innerFill) + .frame(width: inner.width, height: inner.height) + .opacity(overallOpacity(pulse: pulse)) + } + .frame(width: frameSize.width, height: frameSize.height) + .overlay( + Capsule().strokeBorder( + contrast == .increased ? Nearlight.rosewood : Nearlight.quiet, + lineWidth: 1 + ) + ) + .accessibilityHidden(true) + .animation(reduceMotion ? nil : Nearlight.settle, value: innerSize) + } + + /// Increase Contrast promotes the gradient to solid Rosewood. + private var innerFill: AnyShapeStyle { + if contrast == .increased { + return AnyShapeStyle(Nearlight.rosewood) + } + return AnyShapeStyle( + LinearGradient( + stops: [ + .init(color: Nearlight.rosewood.opacity(0.35), location: 0), + .init(color: Nearlight.rosewood.opacity(1), location: 0.5), + .init(color: Nearlight.rosewood.opacity(0.55), location: 1), + ], + startPoint: .top, endPoint: .bottom + ) + ) + } + + /// Whether a pulse TimelineView should run for this phase. + private var pulseWanted: Bool { switch phase { case .connecting, .waiting, .thinking: return !reduceMotion default: return false } } + /// Triangle wave 0→1→0 over the 2.4s cycle from the timeline date. + private func pulseValue(at date: Date) -> Double { + let t = date.timeIntervalSinceReferenceDate.truncatingRemainder(dividingBy: 2.4) / 2.4 + return t < 0.5 ? t * 2 : (1 - t) * 2 + } + /// Inner capsule geometry per spec table. `a` is the smoothed level. + /// Reduce Motion: static 16×40 (idle/listening) and 16×48 (pending/ + /// thinking/speaking) with no level-driven resizing. private var innerSize: CGSize { let base = baseInner + if reduceMotion { + switch phase { + case .waiting, .thinking, .speaking: + return CGSize(width: base.width, height: base.height + (compact ? 3 : 8)) + default: + return base + } + } switch phase { case .listening(let a): let level = min(max(a, 0), 1) @@ -107,7 +136,7 @@ struct LightwellView: View { } } - private var overallOpacity: Double { + private func overallOpacity(pulse: Double) -> Double { switch phase { case .connecting, .waiting: return 0.45 + 0.45 * pulse diff --git a/Sources/Features/Conversation/TranscriptListView.swift b/Sources/Features/Conversation/TranscriptListView.swift index aa8c03c..0337ab3 100644 --- a/Sources/Features/Conversation/TranscriptListView.swift +++ b/Sources/Features/Conversation/TranscriptListView.swift @@ -7,9 +7,15 @@ struct TranscriptListView: View { var onClear: () -> Void @State private var confirmClear = false @State private var followingLatest = true + @State private var selectionActive = false @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.dynamicTypeSize) private var typeSize + /// Auto-follow eligibility: never scroll while the user is selecting + /// text or reading history. + private var isFollowingEligible: Bool { + followingLatest && !selectionActive + } private static let deliveryMarkers = #"\[(?:appreciative|calm|concerned|curious|encouraging|excited|happy|joking|quiet|reassuring|reflective|sad|serious|surprised|sympathetic|urgent|whispering|laughter|sighing|breathing|neutral)\]\s*"# var body: some View { @@ -39,8 +45,6 @@ struct TranscriptListView: View { } ForEach(events) { event in transcriptRow(event) } Color.clear.frame(height: 1).id("latest") - .onAppear { followingLatest = true } - .onDisappear { followingLatest = false } } .padding(.horizontal, Nearlight.s20) .padding(.top, Nearlight.s16) @@ -49,23 +53,43 @@ struct TranscriptListView: View { } .scrollDismissesKeyboard(.interactively) .defaultScrollAnchor(.top) + .simultaneousGesture(DragGesture().onChanged { _ in + // Any manual drag suspends automatic following. + followingLatest = false + }) .onChange(of: events.last?.text) { _, _ in - if followingLatest { proxy.scrollTo("latest", anchor: .bottom) } + guard isFollowingEligible else { return } + proxy.scrollTo("latest", anchor: .bottom) } .onChange(of: events.count) { _, _ in - if followingLatest { proxy.scrollTo("latest", anchor: .bottom) } + guard isFollowingEligible else { return } + proxy.scrollTo("latest", anchor: .bottom) + } + .onChange(of: selectionActive) { _, active in + // Beginning text selection suspends auto-follow so the + // user's reading position is never disturbed. + if active { followingLatest = false } } if !followingLatest && !events.isEmpty { - Button { - withAnimation(reduceMotion ? nil : Nearlight.standard) { - proxy.scrollTo("latest", anchor: .bottom) + HStack { + Spacer() + Button { + followingLatest = true + withAnimation(reduceMotion ? nil : Nearlight.standard) { + proxy.scrollTo("latest", anchor: .bottom) + } + } label: { + Label("Latest", systemImage: "arrow.down") + .font(NearlightFont.support(typeSize)) + .padding(.horizontal, Nearlight.s12) + .frame(minHeight: 44) + .background(Nearlight.surface, in: Capsule()) + .overlay(Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1)) } - } label: { - Label("Latest messages", systemImage: "arrow.down") - .font(NearlightFont.support(typeSize)) - .frame(minHeight: 44) + .accessibilityIdentifier("talk.latest") } - .accessibilityIdentifier("talk.latest") + .padding(.horizontal, Nearlight.s12) + .padding(.bottom, Nearlight.s12) } } } @@ -104,6 +128,11 @@ struct TranscriptListView: View { .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) + .onLongPressGesture(minimumDuration: 0.05, pressing: { pressing in + // Text-selection interaction begins with long-press on the row; + // suspend auto-follow while pressing/held. + if pressing { selectionActive = true } + }, perform: {}) .contextMenu { Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text } }