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
This commit is contained in:
snowspeeder committed 2026-09-12 08:27:48 -04:00
1 parent f043d6b078
commit afe37ad5f4
3 files changed
+121 -55

No files matched your search

@@ -144,6 +144,7 @@ private struct ConversationContent: View {
@FocusState private var composerFocused: Bool @FocusState private var composerFocused: Bool
@Environment(\.dynamicTypeSize) private var typeSize @Environment(\.dynamicTypeSize) private var typeSize
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
init(settings: SettingsStore, session: VoiceSession) { init(settings: SettingsStore, session: VoiceSession) {
self.settings = settings self.settings = settings
@@ -171,8 +172,14 @@ private struct ConversationContent: View {
.onChange(of: session.state) { _, state in .onChange(of: session.state) { _, state in
if state == .ready && settings.textMode { composerFocused = true } if state == .ready && settings.textMode { composerFocused = true }
} }
.sensoryFeedback(.success, trigger: session.state == .ready ? true : false) { _, _ in true } .sensoryFeedback(.success, trigger: session.state == .ready) { old, new in
.sensoryFeedback(.error, trigger: session.state.isFailed) { _, _ in true } // 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) // MARK: Welcome (pre-connection, no history)
@@ -378,11 +385,12 @@ private struct ConversationContent: View {
.font(NearlightFont.control(typeSize)) .font(NearlightFont.control(typeSize))
.foregroundStyle(selected ? Nearlight.rosewood : Nearlight.ink) .foregroundStyle(selected ? Nearlight.rosewood : Nearlight.ink)
.frame(maxWidth: .infinity, minHeight: 52) .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)) in: RoundedRectangle(cornerRadius: Nearlight.radiusControl))
.overlay( .overlay(
RoundedRectangle(cornerRadius: Nearlight.radiusControl) 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) .accessibilityIdentifier(id)
@@ -26,9 +26,6 @@ struct LightwellView: View {
/// Compact (24×36) in the conversation status strip; full (64×96) on welcome. /// Compact (24×36) in the conversation status strip; full (64×96) on welcome.
var compact: Bool = false var compact: Bool = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion @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 /// Frame dimensions never animate; inner dimensions multiply by 0.375
/// in the compact frame. /// in the compact frame.
private var frameSize: CGSize { private var frameSize: CGSize {
@@ -41,55 +38,87 @@ struct LightwellView: View {
: base : base
} }
@Environment(\.colorSchemeContrast) private var contrast
var body: some View { var body: some View {
let inner = innerSize Group {
ZStack { if pulseWanted {
Capsule() // Isolated TimelineView: ticks only while pending states are
.fill(Nearlight.surface) // showing; pauses when offscreen/inactive automatically.
Capsule() TimelineView(.periodic(from: .now, by: 0.05)) { context in
.fill( lightwell(pulse: pulseValue(at: context.date))
LinearGradient( }
stops: [ } else {
.init(color: Nearlight.rosewood.opacity(0.35), location: 0), lightwell(pulse: 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))
} }
} }
} }
/// Whether the pulse loop should run for this phase. private func lightwell(pulse: Double) -> some View {
private var pulseActive: Bool { 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 { switch phase {
case .connecting, .waiting, .thinking: return !reduceMotion case .connecting, .waiting, .thinking: return !reduceMotion
default: return false 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. /// 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 { private var innerSize: CGSize {
let base = baseInner 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 { switch phase {
case .listening(let a): case .listening(let a):
let level = min(max(a, 0), 1) 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 { switch phase {
case .connecting, .waiting: case .connecting, .waiting:
return 0.45 + 0.45 * pulse return 0.45 + 0.45 * pulse
@@ -7,9 +7,15 @@ struct TranscriptListView: View {
var onClear: () -> Void var onClear: () -> Void
@State private var confirmClear = false @State private var confirmClear = false
@State private var followingLatest = true @State private var followingLatest = true
@State private var selectionActive = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.dynamicTypeSize) private var typeSize @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*"# 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 { var body: some View {
@@ -39,8 +45,6 @@ struct TranscriptListView: View {
} }
ForEach(events) { event in transcriptRow(event) } ForEach(events) { event in transcriptRow(event) }
Color.clear.frame(height: 1).id("latest") Color.clear.frame(height: 1).id("latest")
.onAppear { followingLatest = true }
.onDisappear { followingLatest = false }
} }
.padding(.horizontal, Nearlight.s20) .padding(.horizontal, Nearlight.s20)
.padding(.top, Nearlight.s16) .padding(.top, Nearlight.s16)
@@ -49,23 +53,43 @@ struct TranscriptListView: View {
} }
.scrollDismissesKeyboard(.interactively) .scrollDismissesKeyboard(.interactively)
.defaultScrollAnchor(.top) .defaultScrollAnchor(.top)
.simultaneousGesture(DragGesture().onChanged { _ in
// Any manual drag suspends automatic following.
followingLatest = false
})
.onChange(of: events.last?.text) { _, _ in .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 .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 { if !followingLatest && !events.isEmpty {
Button { HStack {
withAnimation(reduceMotion ? nil : Nearlight.standard) { Spacer()
proxy.scrollTo("latest", anchor: .bottom) 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: { .accessibilityIdentifier("talk.latest")
Label("Latest messages", systemImage: "arrow.down")
.font(NearlightFont.support(typeSize))
.frame(minHeight: 44)
} }
.accessibilityIdentifier("talk.latest") .padding(.horizontal, Nearlight.s12)
.padding(.bottom, Nearlight.s12)
} }
} }
} }
@@ -104,6 +128,11 @@ struct TranscriptListView: View {
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
} }
.frame(maxWidth: .infinity, alignment: .leading) .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 { .contextMenu {
Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text } Button("Copy message", systemImage: "doc.on.doc") { UIPasteboard.general.string = text }
} }