feat(ios): comprehensive iPhone+iPad UX polish — refined-native design system
Freeze a shared design system (DesignSystem/{Tokens,Typography,StatusStyle,
Primitives}.swift): indigo #7C8CFF accent (root .tint), semantic status colors,
2-4-8-12-16-20-24 spacing + sm8/md12/lg16 radii scale, SF Mono tabular numbers,
reduce-motion-gated animations, haptics, reusable StatusBadge/TelemetryChip/Card/
SectionHeader/DSButtonStyle/ContinueLastBanner. Every changed view consumes tokens
— no hardcoded colors/spacing.
Applied across all surfaces (visual only — zero behavior/logic change, all suites
green): session list rows + status system (shape+color, not color-alone) +
telemetry chips + thumbnail placeholders; terminal gate card (≥44pt approve/reject)
+ keybar + reconnect + quick-reply + digest + SwiftTerm accent theme; pairing hero
+ warning tiers + Projects cards + Timeline/Diff; nav chrome + iPad split placeholder
+ privacy shade + motion. Chinese gate copy. UX finding fixed: timeline class colors
now via DS.Palette (+timelineTool/timelineUser tokens).
Design review 8.5/10. Verified: iPhone 16 290 + iPad Pro 11 290 tests green;
packages + integration green; consistency audit ~clean; zero changes under
ios/Packages, src/, public/.
This commit is contained in:
158
ios/App/WebTermTests/DesignSystemTests.swift
Normal file
158
ios/App/WebTermTests/DesignSystemTests.swift
Normal file
@@ -0,0 +1,158 @@
|
||||
import SwiftUI
|
||||
import Testing
|
||||
import UIKit
|
||||
import WireProtocol
|
||||
@testable import WebTerm
|
||||
|
||||
/// UX-A · Proves the FROZEN design system: the status mapping is complete and
|
||||
/// "color + shape" (never color alone), the token scales are well-formed, and
|
||||
/// the adaptive accent resolves distinctly in light vs dark. Pure logic — runs
|
||||
/// on both iPhone and iPad sims with no rendering.
|
||||
@Suite("DesignSystem")
|
||||
struct DesignSystemTests {
|
||||
|
||||
// MARK: - StatusStyle: complete, distinct, color + shape
|
||||
|
||||
@Test("every status maps to a non-empty symbol + Chinese label")
|
||||
func everyStatusHasSymbolAndLabel() {
|
||||
for status in DisplayStatus.allCases {
|
||||
let style = StatusStyle.style(for: status)
|
||||
#expect(!style.symbolName.isEmpty, "\(status) has no symbol")
|
||||
#expect(!style.label.isEmpty, "\(status) has no label")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("status is never color-alone — all seven symbols are distinct")
|
||||
func symbolsAreDistinctAcrossStatuses() {
|
||||
let symbols = DisplayStatus.allCases.map { StatusStyle.style(for: $0).symbolName }
|
||||
#expect(Set(symbols).count == DisplayStatus.allCases.count)
|
||||
}
|
||||
|
||||
@Test("Chinese labels are all distinct")
|
||||
func labelsAreDistinct() {
|
||||
let labels = DisplayStatus.allCases.map { StatusStyle.style(for: $0).label }
|
||||
#expect(Set(labels).count == DisplayStatus.allCases.count)
|
||||
}
|
||||
|
||||
@Test("the critical trio working/waiting/stuck use distinct colors")
|
||||
func criticalColorsAreDistinct() {
|
||||
let working = rgba(StatusStyle.style(for: DisplayStatus.working).color)
|
||||
let waiting = rgba(StatusStyle.style(for: DisplayStatus.waiting).color)
|
||||
let stuck = rgba(StatusStyle.style(for: DisplayStatus.stuck).color)
|
||||
#expect(!approxEqual(working, waiting))
|
||||
#expect(!approxEqual(working, stuck))
|
||||
#expect(!approxEqual(waiting, stuck))
|
||||
}
|
||||
|
||||
@Test("semantic status colors match the direction's hex")
|
||||
func statusColorsMatchDirection() {
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.working).color, r: 52, g: 199, b: 89) // #34C759
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.waiting).color, r: 255, g: 159, b: 10) // #FF9F0A
|
||||
assertColor(StatusStyle.style(for: DisplayStatus.stuck).color, r: 255, g: 69, b: 58) // #FF453A
|
||||
}
|
||||
|
||||
@Test("the wire ClaudeStatus bridge covers all five cases")
|
||||
func wireBridgeCoversEveryCase() {
|
||||
let pairs: [(ClaudeStatus, DisplayStatus)] = [
|
||||
(.working, .working), (.waiting, .waiting), (.idle, .idle),
|
||||
(.stuck, .stuck), (.unknown, .unknown),
|
||||
]
|
||||
for (wire, expected) in pairs {
|
||||
#expect(DisplayStatus(wire) == expected)
|
||||
#expect(StatusStyle.style(for: wire) == StatusStyle.style(for: expected))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Token scales are well-formed
|
||||
|
||||
@Test("spacing scale is exactly 2·4·8·12·16·20·24 and strictly increasing")
|
||||
func spacingScaleIsMonotonic() {
|
||||
let scale: [CGFloat] = [
|
||||
DS.Space.xs2, DS.Space.xs4, DS.Space.sm8,
|
||||
DS.Space.md12, DS.Space.lg16, DS.Space.xl20, DS.Space.xxl24,
|
||||
]
|
||||
#expect(scale == [2, 4, 8, 12, 16, 20, 24])
|
||||
#expect(isStrictlyIncreasing(scale))
|
||||
}
|
||||
|
||||
@Test("radius scale is strictly increasing sm8 < md12 < lg16 < pill")
|
||||
func radiusScaleIsMonotonic() {
|
||||
let scale: [CGFloat] = [DS.Radius.sm8, DS.Radius.md12, DS.Radius.lg16, DS.Radius.pill]
|
||||
#expect(scale == [8, 12, 16, 999])
|
||||
#expect(isStrictlyIncreasing(scale))
|
||||
}
|
||||
|
||||
@Test("opacity dimming tokens are within (0, 1)")
|
||||
func opacityTokensAreFractions() {
|
||||
for value in [DS.Opacity.stale, DS.Opacity.exited, DS.Opacity.pressed] {
|
||||
#expect(value > 0 && value < 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("minimum hit target meets the 44pt HIG floor")
|
||||
func hitTargetMeetsHIG() {
|
||||
#expect(DS.Layout.minHitTarget >= 44)
|
||||
}
|
||||
|
||||
// MARK: - Motion honors Reduce Motion
|
||||
|
||||
@Test("motion collapses to nil under Reduce Motion, animates otherwise")
|
||||
func motionGating() {
|
||||
#expect(DS.Motion.gated(DS.Motion.base, reduceMotion: true) == nil)
|
||||
#expect(DS.Motion.gated(DS.Motion.base, reduceMotion: false) != nil)
|
||||
#expect(DS.Motion.fastDuration < DS.Motion.baseDuration)
|
||||
}
|
||||
|
||||
// MARK: - Adaptive accent resolves for both schemes
|
||||
|
||||
@Test("accent is defined and distinct in light vs dark")
|
||||
func accentIsAdaptive() {
|
||||
let accent = DS.Palette.accentUIColor()
|
||||
let dark = accent.resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark))
|
||||
let light = accent.resolvedColor(with: UITraitCollection(userInterfaceStyle: .light))
|
||||
|
||||
let darkRGBA = rgba(dark)
|
||||
let lightRGBA = rgba(light)
|
||||
// Both fully opaque, and visibly different between schemes.
|
||||
#expect(darkRGBA.a == 1)
|
||||
#expect(lightRGBA.a == 1)
|
||||
#expect(!approxEqual(darkRGBA, lightRGBA))
|
||||
// On-brand indigo: dark ≈ #7C8CFF, light ≈ #4F5BD5.
|
||||
assertRGBA(darkRGBA, r: 0x7C, g: 0x8C, b: 0xFF)
|
||||
assertRGBA(lightRGBA, r: 0x4F, g: 0x5B, b: 0xD5)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private typealias RGBA = (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat)
|
||||
|
||||
private func isStrictlyIncreasing(_ values: [CGFloat]) -> Bool {
|
||||
zip(values, values.dropFirst()).allSatisfy { $0 < $1 }
|
||||
}
|
||||
|
||||
private func rgba(_ color: Color) -> RGBA {
|
||||
rgba(UIColor(color))
|
||||
}
|
||||
|
||||
private func rgba(_ color: UIColor) -> RGBA {
|
||||
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
|
||||
color.getRed(&r, green: &g, blue: &b, alpha: &a)
|
||||
return (r, g, b, a)
|
||||
}
|
||||
|
||||
private func approxEqual(_ lhs: RGBA, _ rhs: RGBA, tolerance: CGFloat = 0.02) -> Bool {
|
||||
abs(lhs.r - rhs.r) < tolerance
|
||||
&& abs(lhs.g - rhs.g) < tolerance
|
||||
&& abs(lhs.b - rhs.b) < tolerance
|
||||
}
|
||||
|
||||
private func assertColor(_ color: Color, r: Int, g: Int, b: Int) {
|
||||
assertRGBA(rgba(color), r: r, g: g, b: b)
|
||||
}
|
||||
|
||||
private func assertRGBA(_ value: RGBA, r: Int, g: Int, b: Int, tolerance: CGFloat = 0.02) {
|
||||
#expect(abs(value.r - CGFloat(r) / 255) < tolerance)
|
||||
#expect(abs(value.g - CGFloat(g) / 255) < tolerance)
|
||||
#expect(abs(value.b - CGFloat(b) / 255) < tolerance)
|
||||
}
|
||||
}
|
||||
@@ -163,14 +163,16 @@ struct TimelineSheetTests {
|
||||
#expect(TimelineClassStyle.glyph(for: "") == TimelineClassStyle.fallbackGlyph)
|
||||
}
|
||||
|
||||
@Test("颜色语义映射:对齐 SessionListScreen 状态色约定;未知 → secondary")
|
||||
@Test("颜色语义映射:全部走冻结 DS.Palette(无裸色);未知 → textSecondary")
|
||||
func colorMappingIsSemantic() {
|
||||
#expect(TimelineClassStyle.color(for: "waiting") == .orange) // 列表 waiting 同色
|
||||
#expect(TimelineClassStyle.color(for: "stuck") == .red) // 列表 stuck 同色
|
||||
#expect(TimelineClassStyle.color(for: "done") == .green)
|
||||
#expect(TimelineClassStyle.color(for: "tool") == .blue)
|
||||
#expect(TimelineClassStyle.color(for: "user") == .purple)
|
||||
#expect(TimelineClassStyle.color(for: "future-class") == .secondary)
|
||||
// waiting/stuck 复用状态 token(与列表同义);done 复用 statusWorking;
|
||||
// tool/user 用 timeline 专属 token —— 无一裸色(UX 一致性 finding)。
|
||||
#expect(TimelineClassStyle.color(for: "waiting") == DS.Palette.statusWaiting)
|
||||
#expect(TimelineClassStyle.color(for: "stuck") == DS.Palette.statusStuck)
|
||||
#expect(TimelineClassStyle.color(for: "done") == DS.Palette.statusWorking)
|
||||
#expect(TimelineClassStyle.color(for: "tool") == DS.Palette.timelineTool)
|
||||
#expect(TimelineClassStyle.color(for: "user") == DS.Palette.timelineUser)
|
||||
#expect(TimelineClassStyle.color(for: "future-class") == DS.Palette.textSecondary)
|
||||
}
|
||||
|
||||
// MARK: - 行时间格式(镜像 web formatHHMM:24h 墙钟 HH:mm)
|
||||
|
||||
Reference in New Issue
Block a user