fix(ui): 优化公共控件反馈与文字可读性

This commit is contained in:
ysicing committed 2026-10-02 21:24:11 +08:00
1 parent 04a7909dbf
commit 7b789b1ec2
4 files changed
+152 -14

No files matched your search

+7
View File
@@ -176,6 +176,13 @@ Panel height updates coalesce absolute content measurements within a layout cycl
next cycle; they never synchronously accumulate viewport deltas or replace actual interaction state.
Regression coverage includes `StatusPanelLayoutTests`.
Shared selection controls use `DS.Motion.select` to keep keyboard and eventless activation
immediate; pointer selection retains each control's existing transition. `dsSelectionAnimation`
removes self-drawn selection movement when Reduce Motion is enabled. Custom button press feedback
is separate from hover and disabled state, while interactive system glass keeps native feedback.
Supporting text colors are tested against window, card and elevated solid backgrounds in both
appearances. These policies add no timers or retained per-interaction state.
## Rest timers
`RestSession` uses absolute `mach_continuous_time` deadlines and recalculates after wake instead
@@ -297,7 +297,7 @@ struct PillSwitch<Value: Hashable>: View {
HStack(spacing: 0) {
ForEach(options, id: \.value) { option in
let selected = option.value == selection
Button { selection = option.value } label: {
Button { DS.Motion.select { selection = option.value } } label: {
Text(option.title)
.dsFont(.xs, weight: .semibold)
.foregroundStyle(selected ? DS.Palette.onPrimary : DS.Palette.textSecondary)
@@ -316,7 +316,7 @@ struct PillSwitch<Value: Hashable>: View {
}
.padding(DS.Space.s1 / 2)
.background(DS.Palette.track, in: Capsule())
.animation(DS.Motion.quick, value: selection)
.dsSelectionAnimation(DS.Motion.quick, value: selection)
.fixedSize()
}
}
@@ -392,9 +392,9 @@ private struct DSButtonBody: View {
.frame(height: DS.Size.controlHeight)
.contentShape(Capsule())
.modifier(GlassButtonSurface(kind: kind, active: active, isInsideGlass: isInsideGlass))
.opacity(isEnabled ? 1 : 0.5)
.onHover { hovering = $0 }
.animation(DS.Motion.quick, value: hovering)
.opacity(buttonOpacity)
}
private var solidBody: some View {
@@ -409,13 +409,20 @@ private struct DSButtonBody: View {
RoundedRectangle(cornerRadius: DS.Radius.md).strokeBorder(DS.Palette.border, lineWidth: DS.Size.stroke)
}
}
.opacity(isEnabled ? 1 : 0.5)
.contentShape(Rectangle())
.onHover { hovering = $0 }
.animation(DS.Motion.quick, value: hovering)
.opacity(buttonOpacity)
}
private var active: Bool { hovering || configuration.isPressed }
private var active: Bool { isEnabled && (hovering || configuration.isPressed) }
private var buttonOpacity: Double {
guard isEnabled else { return 0.5 }
// 原生 interactive glass 自带按压效果;自绘按钮需区别悬停和按下,且立即响应。
let systemFeedback = DS.Glass.isAvailable && (kind == .primary || (kind == .secondary && !isInsideGlass))
return configuration.isPressed && !systemFeedback ? 0.65 : 1
}
private var foreground: Color {
switch kind {
@@ -514,7 +521,7 @@ struct SidebarButton: View {
@State private var hovering = false
var body: some View {
Button(action: action) {
Button { DS.Motion.select(action) } label: {
HStack(spacing: DS.Space.s2) {
Image(systemName: symbol)
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
@@ -565,7 +572,7 @@ extension View {
.fill(DS.Palette.sidebarSelected)
.frame(width: selected.width, height: selected.height)
.offset(x: selected.minX, y: selected.minY)
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: selected)
.dsSelectionAnimation(.spring(response: 0.35, dampingFraction: 0.8), value: selected)
}
}
.allowsHitTesting(false)
@@ -617,7 +624,7 @@ private struct SidebarGlider: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.animation(.spring(response: 0.45, dampingFraction: 0.6), value: selected)
.dsSelectionAnimation(.spring(response: 0.45, dampingFraction: 0.6), value: selected)
}
}
}
@@ -638,7 +645,7 @@ struct SegmentedControl<Value: Hashable>: View {
ForEach(options, id: \.value) { option in
let selected = option.value == selection
Button {
selection = option.value
DS.Motion.select { selection = option.value }
} label: {
Text(option.title)
.dsFont(.sm)
@@ -661,7 +668,7 @@ struct SegmentedControl<Value: Hashable>: View {
}
}
.background(DS.Palette.track, in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous))
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: selection)
.dsSelectionAnimation(.spring(response: 0.3, dampingFraction: 0.85), value: selection)
}
}
@@ -687,7 +694,7 @@ struct DSToggle: View {
private var solidBody: some View {
Button {
isOn.toggle()
DS.Motion.select { isOn.toggle() }
} label: {
ZStack(alignment: isOn ? .trailing : .leading) {
Capsule().fill(isOn ? DS.Palette.primary : DS.Palette.neutral300)
@@ -701,7 +708,7 @@ struct DSToggle: View {
.contentShape(Capsule())
}
.buttonStyle(.plain)
.animation(DS.Motion.quick, value: isOn)
.dsSelectionAnimation(DS.Motion.quick, value: isOn)
.accessibilityLabel(label)
.accessibilityValue(isOn ? tr("开") : tr("关"))
}
@@ -21,8 +21,9 @@ extension DS {
// 文字
static let textPrimary = Color.dynamic(light: 0x111827, dark: 0xF3F4F6)
static let textSecondary = Color.dynamic(light: 0x6B7280, dark: 0x9CA3AF)
static let textTertiary = Color.dynamic(light: 0x9CA3AF, dark: 0x6B7280)
static let textSecondary = Color.dynamic(light: 0x4B5563, dark: 0xB6BDC7)
/// 小字号辅助说明在窗口与卡片底色上均保持可读对比度。
static let textTertiary = Color.dynamic(light: 0x626B78, dark: 0x9CA3AF)
static let onPrimary = Color.dynamic(light: 0xFFFFFF, dark: 0xFFFFFF)
// XStats 的统一配色:浅色为白底蓝色,深色为黑底蓝色。
@@ -206,6 +207,14 @@ extension DS {
/// 使用统计的来源/时间筛选仅淡入选中底色,不移动正在阅读的数据。
static let usageSelection = Animation.timingCurve(0.23, 1, 0.32, 1, duration: 0.18)
static let quick = Animation.easeOut(duration: 0.15)
/// 键盘及无指针事件的选择即时生效;指针选择沿用控件自身的短过渡。
@MainActor
static func select(event: NSEvent.EventType? = NSApp?.currentEvent?.type, _ action: () -> Void) {
var transaction = Transaction()
transaction.disablesAnimations = event != .leftMouseDown && event != .leftMouseUp
withTransaction(transaction, action)
}
}
/// 温度阈值(°C),用于着色
@@ -236,3 +245,20 @@ extension View {
shadow(color: level.color, radius: level.radius, x: 0, y: level.y)
}
}
/// 自绘选中块的位移服从系统偏好;颜色、按下态等即时反馈保持可见。
private struct DSSelectionMotion<Value: Equatable>: ViewModifier {
let animation: Animation
let value: Value
@Environment(\.accessibilityReduceMotion) private var reduceMotion
func body(content: Content) -> some View {
content.animation(reduceMotion ? nil : animation, value: value)
}
}
extension View {
func dsSelectionAnimation<Value: Equatable>(_ animation: Animation, value: Value) -> some View {
modifier(DSSelectionMotion(animation: animation, value: value))
}
}
@@ -0,0 +1,98 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import AppKit
import SwiftUI
import Testing
@testable import XStatsUI
@MainActor
struct DesignAccessibilityTests {
@Test(arguments: [false, true])
func supportingTextRemainsReadableOnWindowAndCards(dark: Bool) throws {
let appearance = try #require(NSAppearance(named: dark ? .darkAqua : .aqua))
for text in [DS.Palette.textSecondary, DS.Palette.textTertiary] {
for background in [DS.Palette.background, DS.Palette.surface, DS.Palette.elevated] {
#expect(try contrast(text, on: background, appearance: appearance) >= 4.5)
}
}
}
private func contrast(_ foreground: Color, on background: Color, appearance: NSAppearance) throws -> Double {
let a = try luminance(foreground, appearance: appearance)
let b = try luminance(background, appearance: appearance)
return (max(a, b) + 0.05) / (min(a, b) + 0.05)
}
private func luminance(_ color: Color, appearance: NSAppearance) throws -> Double {
var resolved: NSColor?
appearance.performAsCurrentDrawingAppearance {
resolved = NSColor(color).usingColorSpace(.sRGB)
}
let rgb = try #require(resolved)
let components: [Double] = [Double(rgb.redComponent), Double(rgb.greenComponent), Double(rgb.blueComponent)]
let values: [Double] = components.map { value in
if value <= 0.04045 { return value / 12.92 }
return pow((value + 0.055) / 1.055, 2.4)
}
return 0.2126 * values[0] + 0.7152 * values[1] + 0.0722 * values[2]
}
}
@MainActor
@Observable
private final class SelectionFixture {
var value = 0
}
@MainActor
private final class SelectionTransactions {
var values: [(value: Int, animated: Bool)] = []
}
private struct SelectionTransactionProbe: NSViewRepresentable {
let value: Int
let transactions: SelectionTransactions
func makeNSView(context: Context) -> NSView { NSView() }
func updateNSView(_ view: NSView, context: Context) {
transactions.values.append((value, context.transaction.animation != nil && !context.transaction.disablesAnimations))
}
}
private struct SelectionFixtureView: View {
let model: SelectionFixture
let transactions: SelectionTransactions
var body: some View {
SelectionTransactionProbe(value: model.value, transactions: transactions)
.frame(width: 160, height: 40)
.dsSelectionAnimation(DS.Motion.quick, value: model.value)
}
}
@MainActor
struct DesignSelectionMotionTests {
@Test(arguments: [NSEvent.EventType?.some(.leftMouseUp), .some(.keyDown), .some(.keyUp), .none])
func selectionTransactionsRespectInputAndAccessibility(event: NSEvent.EventType?) async throws {
let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
let model = SelectionFixture()
let transactions = SelectionTransactions()
let hosting = NSHostingView(rootView: SelectionFixtureView(model: model, transactions: transactions))
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 160, height: 40),
styleMask: .borderless, backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentView = hosting
window.orderFront(nil)
defer { window.orderOut(nil); window.contentView = nil; window.close() }
hosting.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(40))
DS.Motion.select(event: event) { model.value = 1 }
hosting.layoutSubtreeIfNeeded()
try await Task.sleep(for: .milliseconds(40))
let selected = try #require(transactions.values.last(where: { $0.value == 1 }))
#expect(selected.animated == (!reduceMotion && event == .leftMouseUp))
#expect(model.value == 1)
}
}