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

@@ -5,23 +5,15 @@ import UIKit
import VisionKit
#endif
/// T-iOS-12 · Pairing screen: QR scan (real device only) + manual URL entry +
/// probe UI. Pure presentation over `PairingViewModel` every rule (input
/// validation, the zero-network-before-confirm gate, §5.4 warning tiers,
/// error copy/actions) lives in the VM where it is unit-tested.
///
/// Presentation-agnostic on purpose: T-iOS-15 pushes it as the first-run
/// screen, and the session list header presents the SAME screen as a sheet to
/// add/switch hosts (the " host " of the task's step list).
///
/// Scanner: VisionKit `DataScannerViewController` compiled out for the
/// simulator (`#if targetEnvironment(simulator)`), where manual entry is the
/// pairing path; on device the entry also hides when scanning is unsupported.
/// `NSCameraUsageDescription` is already declared (project.yml, plan §5.2).
/// T-iOS-12 · Pairing screen: QR scan (device only) + manual URL entry + probe.
/// Pure presentation over `PairingViewModel` every rule (input validation, the
/// zero-network-before-confirm gate, §5.4 warning tiers, error copy/actions)
/// lives in the VM where it is unit-tested. Presented first-run (T-iOS-15) and
/// as an add/switch-host sheet. Scanner (`DataScannerViewController`) is compiled
/// out on the simulator, where manual entry is the pairing path.
struct PairingScreen: View {
@Bindable var viewModel: PairingViewModel
/// Navigate-on-paired hook for the T-iOS-15 wiring.
var onPaired: (HostRegistry.Host) -> Void = { _ in }
var onPaired: (HostRegistry.Host) -> Void = { _ in } // T-iOS-15 navigate hook
@State private var manualURLText = ""
@State private var isShowingScanner = false
@@ -35,7 +27,6 @@ struct PairingScreen: View {
onPaired(paired)
}
}
@ViewBuilder private var content: some View {
switch viewModel.phase {
case .idle:
@@ -50,42 +41,64 @@ struct PairingScreen: View {
pairedView(host)
}
}
// MARK: - Idle: manual entry + scan entry
private var idleView: some View {
Form {
Section(ScreenCopy.manualSectionTitle) {
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.accessibilityIdentifier("pairing.urlField")
Button(ScreenCopy.manualSubmit) {
viewModel.submitManualURL(manualURLText)
ScrollView {
VStack(spacing: DS.Space.xl20) {
VStack(spacing: DS.Space.md12) { // inviting hero
Image(systemName: "desktopcomputer")
.font(DS.Typography.largeTitle)
.foregroundStyle(DS.Palette.accent)
.padding(.top, DS.Space.sm8)
Text(ScreenCopy.heroTitle)
.font(DS.Typography.title)
.foregroundStyle(DS.Palette.textPrimary)
Text(ScreenCopy.heroSubtitle)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
.multilineTextAlignment(.center)
}
.accessibilityIdentifier("pairing.submitButton")
}
if let rejection = viewModel.inputRejection {
Section {
Text(rejection).foregroundStyle(.red)
.frame(maxWidth: .infinity)
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.manualSectionTitle)
TextField(ScreenCopy.manualPlaceholder, text: $manualURLText)
.font(DS.Typography.mono())
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.go)
.onSubmit { viewModel.submitManualURL(manualURLText) }
.accessibilityIdentifier("pairing.urlField")
Divider()
Button(ScreenCopy.manualSubmit) {
DS.Haptics.selection()
viewModel.submitManualURL(manualURLText)
}
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.submitButton")
}
}
}
if PairingScanAvailability.isAvailable {
Section {
if let rejection = viewModel.inputRejection {
Label(rejection, systemImage: "exclamationmark.circle.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
.frame(maxWidth: .infinity, alignment: .leading)
}
if PairingScanAvailability.isAvailable {
Button {
scannerError = nil
isShowingScanner = true
} label: {
Label(ScreenCopy.scanButton, systemImage: "qrcode.viewfinder")
}
.buttonStyle(DSButtonStyle(kind: .secondary)) // hidden on sim
}
}
Section {
Text(ScreenCopy.qrHint)
.font(.footnote)
.foregroundStyle(.secondary)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(DS.Space.lg16)
}
.sheet(isPresented: $isShowingScanner) { scannerSheet }
}
@@ -105,138 +118,189 @@ struct PairingScreen: View {
)
if let scannerError {
Text(scannerError)
.foregroundStyle(.red)
.padding()
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.statusStuck)
.padding(DS.Space.md12)
.background(.thinMaterial, in: RoundedRectangle(
cornerRadius: Metrics.errorCornerRadius
cornerRadius: DS.Radius.sm8
))
.padding()
.padding(DS.Space.lg16)
}
}
#endif
}
// MARK: - Probing / paired
private func probingView(_ pending: PairingViewModel.PendingHost) -> some View {
VStack(spacing: Metrics.stackSpacing) {
VStack(spacing: DS.Space.lg16) {
ProgressView()
Text(ScreenCopy.probing(pending.displayAddress))
.foregroundStyle(.secondary)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
.multilineTextAlignment(.center)
}
.padding()
.padding(DS.Space.xl20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func pairedView(_ host: HostRegistry.Host) -> some View {
VStack(spacing: Metrics.stackSpacing) {
VStack(spacing: DS.Space.md12) {
Image(systemName: "checkmark.circle.fill")
.font(.largeTitle)
.foregroundStyle(.green)
.font(DS.Typography.largeTitle)
.foregroundStyle(DS.Palette.statusWorking)
Text(ScreenCopy.paired(host.name))
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
}
.padding()
.padding(DS.Space.xl20)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { DS.Haptics.success() }
}
}
// MARK: - Confirm page (parsed address + §5.4 warning tier + name)
/// Confirm page parsed address + §5.4 warning tier + host name.
private struct ConfirmHostView: View {
let pending: PairingViewModel.PendingHost
@Bindable var viewModel: PairingViewModel
var body: some View {
Form {
Section(ScreenCopy.confirmSectionTitle) {
// Single-point-derived scheme://host[:port] never hand-built.
Text(pending.displayAddress)
.font(.system(.body, design: .monospaced))
TextField(ScreenCopy.namePlaceholder, text: $viewModel.hostName)
ScrollView {
VStack(spacing: DS.Space.xl20) {
addressCard
warningTier
VStack(spacing: DS.Space.md12) {
Button(ScreenCopy.connect) {
DS.Haptics.selection()
Task { await viewModel.confirmConnect() }
}
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("pairing.confirmButton")
Button(ScreenCopy.cancel) { viewModel.cancel() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
warningSection
Section {
Button(ScreenCopy.connect) {
Task { await viewModel.confirmConnect() }
}
.accessibilityIdentifier("pairing.confirmButton")
Button(ScreenCopy.cancel, role: .cancel) {
viewModel.cancel()
}
.padding(DS.Space.lg16)
}
}
private var addressCard: some View {
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
SectionHeader(title: ScreenCopy.confirmSectionTitle)
// Single-point-derived origin (UITest asserts this exact string).
Text(pending.displayAddress)
.font(DS.Typography.mono())
.foregroundStyle(DS.Palette.textPrimary)
.textSelection(.enabled)
Divider()
TextField(ScreenCopy.namePlaceholder, text: $viewModel.hostName)
.font(DS.Typography.body)
}
}
}
@ViewBuilder private var warningSection: some View {
// §5.4 warning tiers LOGIC frozen (switch cases), only presentation restyled.
@ViewBuilder private var warningTier: some View {
switch pending.warning {
case .none:
EmptyView()
case .tailscaleEncrypted:
Section {
case .tailscaleEncrypted: // positive accent badge encrypted transport
Card {
Label(ScreenCopy.tailscaleBadge, systemImage: "lock.shield")
.foregroundStyle(.green)
}
case .plaintextLAN:
Section {
Label(ScreenCopy.plaintextNotice, systemImage: "eye")
.foregroundStyle(.orange)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.accent)
.frame(maxWidth: .infinity, alignment: .leading)
}
case .plaintextLAN: // subtle amber note
Label(ScreenCopy.plaintextNotice, systemImage: "eye")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
.frame(maxWidth: .infinity, alignment: .leading)
case .publicHostBlocking:
Section {
Label(ScreenCopy.publicWarning, systemImage: "exclamationmark.octagon.fill")
.foregroundStyle(.red)
.font(.headline)
Toggle(ScreenCopy.publicAcknowledge,
isOn: $viewModel.hasAcknowledgedPublicRisk)
if viewModel.needsPublicRiskAcknowledgement {
Text(ScreenCopy.publicAckRequired)
.foregroundStyle(.red)
.font(.footnote)
}
publicWarningCard
}
}
/// Prominent red card + acknowledgement gate (Toggle `hasAcknowledgedPublicRisk`;
/// required-message on `needsPublicRiskAcknowledgement`). Logic unchanged.
private var publicWarningCard: some View {
VStack(alignment: .leading, spacing: DS.Space.md12) {
Label {
Text(ScreenCopy.publicWarning)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
} icon: {
Image(systemName: "exclamationmark.octagon.fill")
.foregroundStyle(DS.Palette.statusStuck)
}
Divider()
Toggle(ScreenCopy.publicAcknowledge, isOn: $viewModel.hasAcknowledgedPublicRisk)
.font(DS.Typography.callout)
.tint(DS.Palette.accent)
if viewModel.needsPublicRiskAcknowledgement {
Label(ScreenCopy.publicAckRequired, systemImage: "arrow.up")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
}
}
.padding(DS.Space.md12)
.background(DS.Palette.card, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.statusStuck, lineWidth: DS.Stroke.hairline)
)
}
}
// MARK: - Failure page (inline copy + recovery action)
/// Failure page inline copy + recovery actions (retry / settings / back).
private struct FailureView: View {
let pending: PairingViewModel.PendingHost
let failure: PairingViewModel.FailureDisplay
let viewModel: PairingViewModel
var body: some View {
Form {
Section(pending.displayAddress) {
Label(failure.message, systemImage: "xmark.octagon")
.foregroundStyle(.red)
}
Section {
if failure.action == .openLocalNetworkSettings {
Button(ScreenCopy.openSettings) { openAppSettings() }
ScrollView {
VStack(spacing: DS.Space.xl20) {
Card {
VStack(alignment: .leading, spacing: DS.Space.sm8) {
Text(pending.displayAddress)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
Label {
Text(failure.message)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
} icon: {
Image(systemName: "xmark.octagon.fill")
.foregroundStyle(DS.Palette.statusStuck)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
Button(ScreenCopy.retry) {
Task { await viewModel.retry() }
}
Button(ScreenCopy.back, role: .cancel) {
viewModel.cancel()
VStack(spacing: DS.Space.md12) {
let needsSettings = failure.action == .openLocalNetworkSettings
if needsSettings {
Button(ScreenCopy.openSettings) { openAppSettings() }
.buttonStyle(DSButtonStyle(kind: .primary))
}
Button(ScreenCopy.retry) { Task { await viewModel.retry() } }
.buttonStyle(DSButtonStyle(kind: needsSettings ? .secondary : .primary))
Button(ScreenCopy.back) { viewModel.cancel() }
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
.padding(DS.Space.lg16)
}
}
/// The app's Settings pane hosts its toggle there is no public
/// deep link straight to (plan §5.2 guidance).
/// The app's Settings pane hosts its toggle (no deep link exists).
private func openAppSettings() {
guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
UIApplication.shared.open(url)
}
}
// MARK: - Scan availability
enum PairingScanAvailability {
/// Simulator: no camera entry hidden, manual entry is the pairing path
/// (task ruling). Device: also requires VisionKit support.
/// (`DataScannerViewController.isSupported` is MainActor-isolated.)
/// Simulator: no camera hidden, manual entry is the pairing path. Device:
/// requires VisionKit support (`isSupported` is MainActor-isolated).
@MainActor static var isAvailable: Bool {
#if targetEnvironment(simulator)
return false
@@ -246,12 +310,9 @@ enum PairingScanAvailability {
}
}
// MARK: - VisionKit scanner (device only)
#if !targetEnvironment(simulator)
/// Thin `DataScannerViewController` wrapper: QR symbology only; the FIRST
/// recognized code wins (the web QR fills the screen no tap-to-pick needed).
/// The payload is handed to the VM untouched validation is the VM's job.
/// Thin `DataScannerViewController` wrapper: QR only; first recognized code wins;
/// payload handed to the VM untouched (validation is the VM's job).
private struct QRScannerView: UIViewControllerRepresentable {
let onCode: (String) -> Void
let onError: (String) -> Void
@@ -275,8 +336,7 @@ private struct QRScannerView: UIViewControllerRepresentable {
do {
try scanner.startScanning()
} catch {
// Explicit surfacing, never a silent swallow (plan §4): camera
// denied/busy shows inline in the sheet; manual entry remains.
// Explicit surfacing (plan §4): shows inline; manual entry remains.
onError(ScreenCopy.scannerStartFailed(error.localizedDescription))
}
}
@@ -309,15 +369,10 @@ private struct QRScannerView: UIViewControllerRepresentable {
}
#endif
// MARK: - Screen constants
private enum Metrics {
static let stackSpacing: CGFloat = 12
static let errorCornerRadius: CGFloat = 8
}
private enum ScreenCopy {
static let title = "配对主机"
static let heroTitle = "连接你的电脑"
static let heroSubtitle = "在同一网络里打开电脑上运行的终端会话——手动输入地址,或扫描它的配对二维码。"
static let manualSectionTitle = "输入地址"
static let manualPlaceholder = "http://192.168.1.5:3000"
static let manualSubmit = "连接"
@@ -332,21 +387,13 @@ private enum ScreenCopy {
static let back = "返回"
static let openSettings = "去设置"
static let tailscaleBadge = "经 Tailscale 加密WireGuard 网络层)"
static let plaintextNotice =
"ws:// 明文连接:键击与终端输出可被同一网络内的设备嗅探。仅限可信 LAN推荐 tailscale servewss"
static let publicWarning =
"这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。"
static let plaintextNotice = "ws:// 明文连接:键击与终端输出可被同一网络内的设备嗅探。仅限可信 LAN推荐 tailscale servewss"
static let publicWarning = "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网"
static let publicAcknowledge = "我已了解风险,仍要连接"
static let publicAckRequired = "请先勾选上面的风险确认,再点连接。"
static func probing(_ address: String) -> String {
"正在验证 \(address)"
}
static func paired(_ name: String) -> String {
"已配对:\(name)"
}
static func probing(_ address: String) -> String { "正在验证 \(address)" }
static func paired(_ name: String) -> String { "已配对:\(name)" }
static func scannerStartFailed(_ reason: String) -> String {
"无法启动相机扫描:\(reason)。可改用手输地址。"
}