From 7b789b1ec280b6afb1a7044d8adeaa7c98e5735a Mon Sep 17 00:00:00 2001 From: ysicing Date: Fri, 2 Oct 2026 21:24:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E4=BC=98=E5=8C=96=E5=85=AC=E5=85=B1?= =?UTF-8?q?=E6=8E=A7=E4=BB=B6=E5=8F=8D=E9=A6=88=E4=B8=8E=E6=96=87=E5=AD=97?= =?UTF-8?q?=E5=8F=AF=E8=AF=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 7 ++ .../Sources/XStatsUI/Design/Components.swift | 31 +++--- .../Sources/XStatsUI/Design/Tokens.swift | 30 +++++- .../DesignAccessibilityTests.swift | 98 +++++++++++++++++++ 4 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 Packages/XStatsKit/Tests/XStatsUITests/DesignAccessibilityTests.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2b3b9e2..a3f9e62 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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 diff --git a/Packages/XStatsKit/Sources/XStatsUI/Design/Components.swift b/Packages/XStatsKit/Sources/XStatsUI/Design/Components.swift index 7de63c7..92106fb 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Design/Components.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Design/Components.swift @@ -297,7 +297,7 @@ struct PillSwitch: 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: 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: 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: 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("关")) } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Design/Tokens.swift b/Packages/XStatsKit/Sources/XStatsUI/Design/Tokens.swift index fdf153f..b3c2aba 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Design/Tokens.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Design/Tokens.swift @@ -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: 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(_ animation: Animation, value: Value) -> some View { + modifier(DSSelectionMotion(animation: animation, value: value)) + } +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/DesignAccessibilityTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/DesignAccessibilityTests.swift new file mode 100644 index 0000000..eaa16fc --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/DesignAccessibilityTests.swift @@ -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) + } +}