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:
Yaojia Wang
2026-07-05 22:00:31 +02:00
parent 823432b1c8
commit 660a40491a
25 changed files with 1742 additions and 650 deletions

View File

@@ -0,0 +1,222 @@
import SwiftUI
import WireProtocol
/// # Primitives reusable SwiftUI building blocks (FROZEN public surface)
///
/// The four component groups compose these by exact name. Each pulls ALL of its
/// constants from `DS`/`StatusStyle`/`DS.Typography` no inline magic. Every
/// primitive ships a `#Preview`.
// MARK: - StatusBadge
/// Color + distinct SF Symbol (+ optional Chinese label) for one status. The
/// VoiceOver label is baked in from `StatusStyle`, so status is conveyed by
/// shape, color AND speech. The symbol scales with Dynamic Type.
struct StatusBadge: View {
let status: DisplayStatus
/// Show the Chinese word next to the symbol (list rows usually don't).
var showsLabel: Bool = false
/// Convenience init from the wire enum.
init(status: DisplayStatus, showsLabel: Bool = false) {
self.status = status
self.showsLabel = showsLabel
}
init(claude: ClaudeStatus, showsLabel: Bool = false) {
self.init(status: DisplayStatus(claude), showsLabel: showsLabel)
}
private var style: StatusStyle { StatusStyle.style(for: status) }
var body: some View {
HStack(spacing: DS.Space.xs4) {
Image(systemName: style.symbolName)
.foregroundStyle(style.color)
.imageScale(.medium)
if showsLabel {
Text(style.label)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(style.label)
}
}
// MARK: - TelemetryChip
/// One pill of monospaced-tabular telemetry (context %, $cost, model, PR).
/// Greys out + desaturates when `isStale`; a `isWarning` chip switches to the
/// waiting/amber semantic color for over-threshold context.
struct TelemetryChip: View {
/// Optional leading SF Symbol.
var systemImage: String? = nil
let text: String
var isStale: Bool = false
var isWarning: Bool = false
var body: some View {
HStack(spacing: DS.Space.xs2) {
if let systemImage {
Image(systemName: systemImage)
}
Text(text)
}
.font(DS.Typography.mono(.caption2))
.lineLimit(1)
.foregroundStyle(isWarning ? DS.Palette.statusWaiting : DS.Palette.textSecondary)
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs2)
.background(.quaternary, in: Capsule())
.opacity(isStale ? DS.Opacity.stale : 1)
.grayscale(isStale ? 1 : 0)
}
}
// MARK: - Card
/// Standard card container: card surface + hairline stroke + `md12` radius +
/// standard padding. The uniform card spec for rows, grid cells and panels.
struct Card<Content: View>: View {
/// Inner padding (defaults to `md12`; pass `sm8` for tight rows).
var padding: CGFloat = DS.Space.md12
@ViewBuilder var content: () -> Content
init(padding: CGFloat = DS.Space.md12, @ViewBuilder content: @escaping () -> Content) {
self.padding = padding
self.content = content
}
var body: some View {
content()
.padding(padding)
.background(DS.Palette.card, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
}
}
// MARK: - SectionHeader
/// A small, secondary section label (Chinese copy passed verbatim by callers).
struct SectionHeader: View {
let title: String
var body: some View {
Text(title)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
// MARK: - DSButtonStyle
/// The App's button style. `primary` = accent-filled, `secondary` = tinted
/// outline, `destructive` = red-filled. Always `minHitTarget` tall, full
/// width, `md12` radius. Press feedback honors Reduce Motion.
struct DSButtonStyle: ButtonStyle {
enum Kind { case primary, secondary, destructive }
var kind: Kind = .primary
func makeBody(configuration: Configuration) -> some View {
DSButtonBody(kind: kind, configuration: configuration)
}
/// Nested view so we can read `@Environment` (a `ButtonStyle` cannot).
/// Must be as accessible as `DSButtonStyle` (opaque `makeBody` requirement).
struct DSButtonBody: View {
let kind: Kind
let configuration: Configuration
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.isEnabled) private var isEnabled
var body: some View {
configuration.label
.font(DS.Typography.body.weight(.semibold))
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
.foregroundStyle(foreground)
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(border)
.opacity(opacity)
.animation(
DS.Motion.gated(DS.Motion.fast, reduceMotion: reduceMotion),
value: configuration.isPressed
)
}
private var foreground: Color {
switch kind {
case .primary, .destructive: return .white
case .secondary: return DS.Palette.accent
}
}
private var background: Color {
switch kind {
case .primary: return DS.Palette.accent
case .destructive: return DS.Palette.statusStuck
case .secondary: return DS.Palette.card
}
}
@ViewBuilder private var border: some View {
if kind == .secondary {
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.accent, lineWidth: DS.Stroke.hairline)
}
}
private var opacity: Double {
if !isEnabled { return DS.Opacity.pressed }
return configuration.isPressed ? DS.Opacity.pressed : 1
}
}
}
// MARK: - Previews
#Preview("StatusBadge") {
VStack(alignment: .leading, spacing: DS.Space.md12) {
ForEach(DisplayStatus.allCases, id: \.self) { status in
StatusBadge(status: status, showsLabel: true)
}
}
.padding(DS.Space.lg16)
}
#Preview("TelemetryChip") {
HStack(spacing: DS.Space.sm8) {
TelemetryChip(text: "ctx 92%", isWarning: true)
TelemetryChip(text: "$0.1234")
TelemetryChip(systemImage: "cpu", text: "opus")
TelemetryChip(text: "PR #7", isStale: true)
}
.padding(DS.Space.lg16)
}
#Preview("Card") {
Card {
VStack(alignment: .leading, spacing: DS.Space.sm8) {
SectionHeader(title: "会话")
Text(verbatim: "web-terminal")
.font(DS.Typography.headline)
Text(verbatim: "2 台设备在看 · 161×50")
.dsMetaText()
}
}
.padding(DS.Space.lg16)
}
#Preview("DSButtonStyle") {
VStack(spacing: DS.Space.md12) {
Button("新建会话") {}.buttonStyle(DSButtonStyle(kind: .primary))
Button("继续上次会话") {}.buttonStyle(DSButtonStyle(kind: .secondary))
Button("结束会话") {}.buttonStyle(DSButtonStyle(kind: .destructive))
}
.tint(DS.Palette.accent)
.padding(DS.Space.lg16)
}

View File

@@ -0,0 +1,86 @@
import SwiftUI
import WireProtocol
/// # Status visuals the SINGLE source (FROZEN public surface)
///
/// Every place that shows a session's Claude-Code status (list rows, badges,
/// thumbnails, project-detail rows, banners) resolves it through here, so the
/// color + shape + label stay identical everywhere. Status is expressed as
/// **color AND a distinct SF Symbol** never color alone (accessibility;
/// color-blind users read the shape). Labels are Chinese for VoiceOver + UI.
/// The seven visual states a status indicator can show. A superset of the wire
/// `ClaudeStatus` (working/waiting/idle/unknown/stuck) plus two App-layer
/// emphasis states:
/// - `pendingApproval` a tool/plan gate is held server-side ("needs me").
/// Outranks status; mirrors `SessionListViewModel.Indicator.pendingApproval`
/// (this type does NOT re-implement that priority callers decide when to
/// use it; here it's only its visual identity).
/// - `exited` the session is over (read-only).
enum DisplayStatus: CaseIterable, Sendable, Equatable {
case working
case waiting
case idle
case stuck
case unknown
case pendingApproval
case exited
/// Bridge from the wire enum. Pure no pending/exited emphasis (callers
/// supply those explicitly, matching the VM's own indicator priority).
init(_ claude: ClaudeStatus) {
switch claude {
case .working: self = .working
case .waiting: self = .waiting
case .idle: self = .idle
case .stuck: self = .stuck
case .unknown: self = .unknown
}
}
}
/// Resolved visuals for one status: a semantic color, a DISTINCT SF Symbol
/// shape, and a Chinese label (used as the VoiceOver string too). The mapping
/// itself is pure no UIKit, deterministic, unit-testable.
struct StatusStyle: Equatable, Sendable {
/// Semantic color from `DS.Palette` (never the sole signal see `symbolName`).
let color: Color
/// SF Symbol name. Distinct across all seven statuses so shape alone
/// disambiguates (color-blind safe).
let symbolName: String
/// Chinese status word shown as an optional label and always as the
/// accessibility label.
let label: String
/// The frozen mapping. Each status (color, distinct symbol, label).
static func style(for status: DisplayStatus) -> StatusStyle {
switch status {
case .working:
// solid filled circle actively running
return StatusStyle(color: DS.Palette.statusWorking, symbolName: "circle.fill", label: "运行中")
case .waiting:
// clock waiting on something
return StatusStyle(color: DS.Palette.statusWaiting, symbolName: "clock.fill", label: "等待中")
case .idle:
// hollow circle quiet/empty (gray, distinct from working's fill)
return StatusStyle(color: DS.Palette.statusIdle, symbolName: "circle", label: "空闲")
case .stuck:
// triangle alarm
return StatusStyle(color: DS.Palette.statusStuck, symbolName: "exclamationmark.triangle.fill", label: "卡住")
case .unknown:
// question mark no signal yet
return StatusStyle(color: DS.Palette.statusUnknown, symbolName: "questionmark.circle", label: "未知")
case .pendingApproval:
// filled ! circle "needs me", the single most important prompt
return StatusStyle(color: DS.Palette.statusWaiting, symbolName: "exclamationmark.circle.fill", label: "等待审批")
case .exited:
// checkered flag session over (mirrors ReconnectBanner's exited icon)
return StatusStyle(color: DS.Palette.statusExited, symbolName: "flag.checkered", label: "已退出")
}
}
/// Convenience bridge from the wire enum.
static func style(for claude: ClaudeStatus) -> StatusStyle {
style(for: DisplayStatus(claude))
}
}

View File

@@ -0,0 +1,197 @@
import SwiftUI
import UIKit
/// # WebTerm Design System token vocabulary (FROZEN public surface)
///
/// The single source of truth for every visual constant in the App layer.
/// Screens/components must reference these tokens never inline colors,
/// spacings, radii, opacities, durations or haptics. Direction: ""
/// (refined native, Apple HIG), dark-mode-first, indigo/violet accent
/// continuing the web selection color.
///
/// Vocabulary (all under `DS.`):
/// - `Palette` adaptive `accent` (indigo) · semantic `status*` colors
/// (color is NEVER the only status signal pair with a symbol,
/// see `StatusStyle`) · `surface`/`card`/`hairline` surfaces ·
/// `textPrimary`/`textSecondary`/`textTertiary`.
/// - `Space` 2·4·8·12·16·20·24 scale (`xs2``xxl24`). No off-scale gaps.
/// - `Radius` `sm8`/`md12`/`lg16`/`pill`.
/// - `Stroke` `hairline` (1pt border width).
/// - `Opacity` `stale`/`exited`/`pressed` dimming multipliers.
/// - `Layout` `minHitTarget` (44pt HIG minimum).
/// - `Motion` `fast`/`base` eased animations + `gated(_:reduceMotion:)`
/// which collapses to `nil` under Reduce Motion.
/// - `Haptics` `selection`/`success`/`warning` (`@MainActor`).
///
/// Companion files: `Typography.swift` (`DS.Typography`), `StatusStyle.swift`
/// (`StatusStyle` / `DisplayStatus`), `Primitives.swift` (reusable views).
enum DS {
// MARK: - Palette
/// Colors. `accent` is asset-free adaptive (no `.xcassets`); the semantic
/// status colors use the direction's exact hex so light/dark stay on-brand.
enum Palette {
// Accent (indigo/violet web selection color #7C8CFF)
// Adaptive: dark #7C8CFF, light #4F5BD5. Used sparingly primary
// actions, selection, active state only.
/// The one accent token. Inject once at the root via `.tint(DS.Palette.accent)`.
static let accent = Color(uiColor: accentUIColor())
/// The accent as a dynamic `UIColor`. Exposed so tests can resolve the
/// two schemes deterministically (`resolvedColor(with:)`) without going
/// through the SwiftUIUIKit bridge.
static func accentUIColor() -> UIColor {
UIColor { trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0x7C / 255.0, green: 0x8C / 255.0, blue: 0xFF / 255.0, alpha: 1)
: UIColor(red: 0x4F / 255.0, green: 0x5B / 255.0, blue: 0xD5 / 255.0, alpha: 1)
}
}
// Semantic status colors (exact hex from the direction)
// These are the ONLY status colors. `StatusStyle` pairs each with a
// distinct SF Symbol so status is never conveyed by color alone.
/// working #34C759 (green).
static let statusWorking = rgb(52, 199, 89)
/// waiting / needs-me #FF9F0A (amber).
static let statusWaiting = rgb(255, 159, 10)
/// stuck #FF453A (red).
static let statusStuck = rgb(255, 69, 58)
/// idle quiet secondary gray (distinguished from `unknown` by shape).
static let statusIdle = Color.secondary
/// unknown / no signal yet gray.
static let statusUnknown = Color.gray
/// exited dimmed secondary (apply `Opacity.exited` on the container).
static let statusExited = Color.secondary
// Timeline event classes (T-iOS-24)
// Semantic colors for the activity-timeline event classes. `waiting`
// and `stuck` reuse the status tokens above (same meaning); `done`
// reuses `statusWorking` (a completed run). `tool`/`user` get their own
// tokens so nothing in the app hardcodes a raw SwiftUI color.
/// tool run indigo, tied to the app accent family (#5E9EFF-ish).
static let timelineTool = rgb(94, 158, 255)
/// user message violet (#AF7BFF), distinct from accent & tool.
static let timelineUser = rgb(175, 123, 255)
// Surfaces & text
/// Base screen background.
static let surface = Color(uiColor: .systemBackground)
/// Card / grouped-content background (a step up from `surface`).
static let card = Color(uiColor: .secondarySystemBackground)
/// Hairline separator/border color.
static let hairline = Color(uiColor: .separator)
/// Primary label color.
static let textPrimary = Color.primary
/// Secondary label color (meta, captions).
static let textSecondary = Color.secondary
/// Tertiary label color (de-emphasized detail).
static let textTertiary = Color(uiColor: .tertiaryLabel)
/// Build an opaque sRGB color from 0255 components.
private static func rgb(_ r: Double, _ g: Double, _ b: Double) -> Color {
Color(.sRGB, red: r / 255, green: g / 255, blue: b / 255, opacity: 1)
}
}
// MARK: - Space
/// Spacing scale 2·4·8·12·16·20·24. Every gap/padding picks one of these;
/// there are no in-between values (the audit's 3/5/6/10/14 all round here).
enum Space {
static let xs2: CGFloat = 2
static let xs4: CGFloat = 4
static let sm8: CGFloat = 8
static let md12: CGFloat = 12
static let lg16: CGFloat = 16
static let xl20: CGFloat = 20
static let xxl24: CGFloat = 24
}
// MARK: - Radius
/// Corner radii. Legacy 6/10 both fold into `sm8`/`md12`.
enum Radius {
static let sm8: CGFloat = 8
static let md12: CGFloat = 12
static let lg16: CGFloat = 16
/// Fully-rounded (capsule/pill).
static let pill: CGFloat = 999
}
// MARK: - Stroke
/// Border widths.
enum Stroke {
/// Hairline card/overlay border.
static let hairline: CGFloat = 1
}
// MARK: - Opacity
/// Dimming multipliers (used with `.opacity()`, sometimes `.grayscale()`).
enum Opacity {
/// Telemetry gone stale (past its TTL).
static let stale: Double = 0.45
/// A session that has exited.
static let exited: Double = 0.55
/// Pressed-state feedback on buttons.
static let pressed: Double = 0.72
}
// MARK: - Layout
/// Layout constants that are not spacings.
enum Layout {
/// HIG minimum touch target (44×44pt).
static let minHitTarget: CGFloat = 44
}
// MARK: - Motion
/// Animation tokens. Subtle, eased (~0.180.25s). ALWAYS route through
/// `gated(_:reduceMotion:)` so Reduce Motion collapses to no motion.
enum Motion {
static let fastDuration: Double = 0.18
static let baseDuration: Double = 0.25
/// Quick affordance (chips, presses).
static let fast = Animation.easeInOut(duration: fastDuration)
/// Standard transition (banners, sheets, list changes).
static let base = Animation.easeInOut(duration: baseDuration)
/// Returns `animation` normally, or `nil` (instant, no motion) when
/// Reduce Motion is enabled. Callers pass the environment flag.
static func gated(_ animation: Animation, reduceMotion: Bool) -> Animation? {
reduceMotion ? nil : animation
}
}
// MARK: - Haptics
/// Tactile feedback for key interactions. `@MainActor` the underlying
/// UIKit generators must be touched on the main thread. Additive to the
/// existing `GateViewModel` gate-arrival haptic (different call sites).
@MainActor
enum Haptics {
/// Light selection tap opening a session, tapping a key.
static func selection() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
/// Success notification a gate approve resolved.
static func success() {
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
/// Warning notification a destructive/reject decision.
static func warning() {
UINotificationFeedbackGenerator().notificationOccurred(.warning)
}
}
}

View File

@@ -0,0 +1,60 @@
import SwiftUI
/// # Typography the SF type ramp (FROZEN public surface)
///
/// All font choices come from `DS.Typography`. The ramp maps to Apple's
/// semantic text styles so everything scales with Dynamic Type (the a11y
/// audit's "keep it scalable" point). Numbers/dimensions/cost/`cols×rows`/
/// timestamps use `mono(_:)` SF Mono with tabular figures so columns line
/// up and digits don't jitter as values change (matches the existing
/// `TelemetryChips`/`Timeline` convention, now centralized).
extension DS {
enum Typography {
// Proportional ramp (Dynamic-Type scaling, semantic styles)
/// Screen hero title.
static let largeTitle = Font.largeTitle
/// Section / prominent title.
static let title = Font.title2
/// Emphasis / card heading.
static let headline = Font.headline
/// Default body text.
static let body = Font.body
/// Slightly smaller body (secondary actions).
static let callout = Font.callout
/// Meta / caption text.
static let caption = Font.caption
// Monospaced (numbers, dimensions, timestamps)
/// SF Mono + tabular figures at the given text style (default `.body`).
/// Use for anything numeric that must align or not jump: `cols×rows`,
/// device/client counts, `$cost`, context %, relative timestamps.
static func mono(_ style: Font.TextStyle = .body) -> Font {
.system(style, design: .monospaced).monospacedDigit()
}
/// The canonical meta-number font: caption-sized mono + tabular.
static let metaMono = mono(.caption)
}
}
// MARK: - MetaText style
/// Secondary + monospaced-tabular styling for a row's numeric meta line
/// ("N · 161×50"). Apply via `.dsMetaText()`.
struct MetaText: ViewModifier {
func body(content: Content) -> some View {
content
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.textSecondary)
}
}
extension View {
/// Style a meta/number line as secondary monospaced-tabular text.
func dsMetaText() -> some View {
modifier(MetaText())
}
}