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:
1 parent
f043d6b078
commit
afe37ad5f4
3 files changed
+103
-37
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,13 +38,49 @@ struct LightwellView: View {
|
|||||||
: base
|
: base
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Environment(\.colorSchemeContrast) private var contrast
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lightwell(pulse: Double) -> some View {
|
||||||
let inner = innerSize
|
let inner = innerSize
|
||||||
ZStack {
|
return ZStack {
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(Nearlight.surface)
|
.fill(Nearlight.surface)
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(
|
.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(
|
LinearGradient(
|
||||||
stops: [
|
stops: [
|
||||||
.init(color: Nearlight.rosewood.opacity(0.35), location: 0),
|
.init(color: Nearlight.rosewood.opacity(0.35), location: 0),
|
||||||
@@ -57,39 +90,35 @@ struct LightwellView: View {
|
|||||||
startPoint: .top, endPoint: .bottom
|
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.
|
/// Whether a pulse TimelineView should run for this phase.
|
||||||
private var pulseActive: Bool {
|
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,24 +53,44 @@ 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 {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
|
followingLatest = true
|
||||||
withAnimation(reduceMotion ? nil : Nearlight.standard) {
|
withAnimation(reduceMotion ? nil : Nearlight.standard) {
|
||||||
proxy.scrollTo("latest", anchor: .bottom)
|
proxy.scrollTo("latest", anchor: .bottom)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Latest messages", systemImage: "arrow.down")
|
Label("Latest", systemImage: "arrow.down")
|
||||||
.font(NearlightFont.support(typeSize))
|
.font(NearlightFont.support(typeSize))
|
||||||
|
.padding(.horizontal, Nearlight.s12)
|
||||||
.frame(minHeight: 44)
|
.frame(minHeight: 44)
|
||||||
|
.background(Nearlight.surface, in: Capsule())
|
||||||
|
.overlay(Capsule().strokeBorder(Nearlight.quiet, lineWidth: 1))
|
||||||
}
|
}
|
||||||
.accessibilityIdentifier("talk.latest")
|
.accessibilityIdentifier("talk.latest")
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, Nearlight.s12)
|
||||||
|
.padding(.bottom, Nearlight.s12)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("Clear this conversation?", isPresented: $confirmClear) {
|
.alert("Clear this conversation?", isPresented: $confirmClear) {
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user