Files
web-terminal/ios/App/WebTerm/DesignSystem/Primitives.swift
Yaojia Wang 660a40491a 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/.
2026-07-05 22:00:31 +02:00

223 lines
7.3 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}