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:
222
ios/App/WebTerm/DesignSystem/Primitives.swift
Normal file
222
ios/App/WebTerm/DesignSystem/Primitives.swift
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user