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

@@ -7,6 +7,13 @@ import WireProtocol
/// staleness, optimistic kill and the navigation signal are all VM logic,
/// unit-tested in `SessionListViewModelTests`.
///
/// UX polish (G1-list): the row is restructured for the 5-second glance
/// prominent title, a `StatusBadge` (semantic color + distinct SF Symbol, never
/// color alone), a monospaced meta line, and telemetry as proper
/// `TelemetryChip`s. Rows are DS `Card`s; active/exited sessions are split under
/// `SectionHeader`s (presentation-only the VM already groups exited last).
/// Every color/spacing/radius/font/motion comes from `DS.*`.
///
/// The T-iOS-15 wiring provides `onOpen` (push `TerminalScreen`, open with
/// `request.sessionId` nil = new session) and `onAddHost` (present the
/// pairing sheet; call `viewModel.reloadHosts()` when it completes).
@@ -24,6 +31,10 @@ struct SessionListScreen: View {
var body: some View {
content
// Accent is not injected app-wide; scope it here so native chrome
// (nav bar, bordered controls) picks up the DS indigo. Presentation
// only no behavior change.
.tint(DS.Palette.accent)
.navigationTitle(ScreenCopy.title)
.toolbar { hostMenu }
.onAppear { viewModel.appeared() }
@@ -55,30 +66,80 @@ struct SessionListScreen: View {
if let message = viewModel.killErrorMessage {
errorRow(message)
}
Button {
viewModel.requestNewSession()
} label: {
Label(ScreenCopy.newSession, systemImage: "plus.circle.fill")
}
.accessibilityIdentifier("sessions.newButton")
ForEach(viewModel.rows) { row in
Button {
viewModel.openSession(id: row.id)
} label: {
SessionRowView(row: row, thumbnail: thumbnailSlot(for: row))
newSessionRow
if !activeRows.isEmpty {
Section {
ForEach(activeRows) { row in sessionRow(row) }
} header: {
SectionHeader(title: ScreenCopy.activeSection)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.kill(sessionId: row.id) }
} label: {
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
}
}
if !exitedRows.isEmpty {
Section {
ForEach(exitedRows) { row in sessionRow(row) }
} header: {
SectionHeader(title: ScreenCopy.exitedSection)
}
}
}
.listStyle(.plain)
.refreshable { await viewModel.refresh() }
}
/// Split the VM's (already exited-last) rows into the two visual groups.
/// Presentation only no ordering/logic decision lives here.
private var activeRows: [SessionListViewModel.SessionRow] {
viewModel.rows.filter { !$0.info.exited }
}
private var exitedRows: [SessionListViewModel.SessionRow] {
viewModel.rows.filter { $0.info.exited }
}
/// Inviting primary entry accent-tinted card row.
private var newSessionRow: some View {
Button {
viewModel.requestNewSession()
} label: {
NewSessionRow()
}
.buttonStyle(.plain)
.accessibilityIdentifier("sessions.newButton")
.listRowSeparator(.hidden)
.listRowInsets(rowInsets(vertical: DS.Space.sm8))
.listRowBackground(Color.clear)
}
private func sessionRow(_ row: SessionListViewModel.SessionRow) -> some View {
Button {
viewModel.openSession(id: row.id)
} label: {
SessionRowView(row: row, thumbnail: thumbnailSlot(for: row))
}
.buttonStyle(.plain)
.listRowSeparator(.hidden)
.listRowInsets(rowInsets(vertical: DS.Space.xs4))
.listRowBackground(Color.clear)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.kill(sessionId: row.id) }
} label: {
Label(ScreenCopy.kill, systemImage: "xmark.circle.fill")
}
}
}
/// Carded-list row inset: full-bleed gutter (`lg16`) + a small vertical gap
/// so adjacent cards breathe.
private func rowInsets(vertical: CGFloat) -> EdgeInsets {
EdgeInsets(
top: vertical, leading: DS.Space.lg16,
bottom: vertical, trailing: DS.Space.lg16
)
}
/// T-iOS-28 · build one row's thumbnail slot. No paired host (defensive
/// rows imply a host) no slot; the request key carries `lastOutputAt`
/// so unchanged sessions render exactly once (pipeline cache).
@@ -97,9 +158,12 @@ struct SessionListScreen: View {
}
private func errorRow(_ message: String) -> some View {
Label(message, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.red)
Label(message, systemImage: "exclamationmark.triangle.fill")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusStuck)
.listRowSeparator(.hidden)
.listRowInsets(rowInsets(vertical: DS.Space.xs4))
.listRowBackground(Color.clear)
}
// MARK: - Empty states
@@ -111,7 +175,7 @@ struct SessionListScreen: View {
Text(ScreenCopy.notPairedHint)
} actions: {
Button(ScreenCopy.addHost) { onAddHost() }
.buttonStyle(.borderedProminent)
.buttonStyle(DSButtonStyle(kind: .primary))
}
}
@@ -122,7 +186,7 @@ struct SessionListScreen: View {
Text(ScreenCopy.noSessionsHint)
} actions: {
Button(ScreenCopy.newSession) { viewModel.requestNewSession() }
.buttonStyle(.borderedProminent)
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("sessions.newButton")
}
}
@@ -162,6 +226,25 @@ struct SessionListScreen: View {
// MARK: - Row
/// Inviting "" card accent icon + accent title on a DS `Card`.
private struct NewSessionRow: View {
var body: some View {
Card(padding: DS.Space.md12) {
HStack(spacing: DS.Space.md12) {
Image(systemName: "plus.circle.fill")
.font(DS.Typography.title)
.foregroundStyle(DS.Palette.accent)
Text(ScreenCopy.newSession)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.accent)
Spacer(minLength: 0)
}
}
}
}
/// One session row: `StatusBadge` · title · mono meta · telemetry chips ·
/// optional live thumbnail laid out inside a DS `Card`.
private struct SessionRowView: View {
let row: SessionListViewModel.SessionRow
/// T-iOS-28 (additive) · trailing live-preview thumbnail; nil = no slot
@@ -169,61 +252,69 @@ private struct SessionRowView: View {
var thumbnail: SessionThumbnailView?
var body: some View {
HStack(alignment: .top, spacing: Metrics.rowSpacing) {
indicator
.frame(width: Metrics.indicatorWidth)
VStack(alignment: .leading, spacing: Metrics.rowInnerSpacing) {
HStack(spacing: Metrics.titleSpacing) {
// T-iOS-23: OSC titles are attacker-controlled already
// sanitized in the VM, rendered verbatim (no Markdown /
// LocalizedStringKey interpretation), one line only.
Text(verbatim: title)
.font(.body)
Card(padding: DS.Space.md12) {
HStack(alignment: .top, spacing: DS.Space.md12) {
// pending / exited outrank the raw status (VM decides pending;
// exited is a row fact) resolved to a single DisplayStatus so
// the badge shows color + distinct shape + Chinese VoiceOver.
StatusBadge(status: displayStatus)
.padding(.top, DS.Space.xs2)
VStack(alignment: .leading, spacing: DS.Space.xs4) {
titleLine
Text(meta)
.dsMetaText()
.lineLimit(1)
if row.isUnread {
unreadDot
if let telemetry = row.telemetry, !telemetry.isEmpty {
TelemetryChips(model: telemetry)
}
}
Text(meta)
.font(.caption)
.foregroundStyle(.secondary)
if let telemetry = row.telemetry, !telemetry.isEmpty {
TelemetryChips(model: telemetry)
if let thumbnail {
Spacer(minLength: DS.Space.sm8)
thumbnail
}
}
if let thumbnail {
Spacer(minLength: Metrics.titleSpacing)
thumbnail
}
}
.opacity(row.info.exited ? Metrics.exitedOpacity : 1)
.opacity(row.info.exited ? DS.Opacity.exited : 1)
.accessibilityElement(children: .combine)
}
/// badge outranks the status dot (VM decides via `indicator`).
@ViewBuilder private var indicator: some View {
switch row.indicator {
case .pendingApproval:
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.accessibilityLabel(ScreenCopy.pendingBadgeLabel)
case .status(let status):
Circle()
.fill(Self.dotColor(status))
.frame(width: Metrics.dotSize, height: Metrics.dotSize)
.padding(.top, Metrics.dotTopPadding)
.accessibilityLabel(Text(status.rawValue))
private var titleLine: some View {
HStack(spacing: DS.Space.sm8) {
// T-iOS-23: OSC titles are attacker-controlled already sanitized
// in the VM, rendered verbatim (no Markdown / LocalizedStringKey
// interpretation), one line only.
Text(verbatim: title)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
if row.isUnread {
unreadDot
}
Spacer(minLength: 0)
}
}
/// Unread dot (T-iOS-23): output newer than the local last-seen watermark.
/// Accent (indigo) continues the web selection color distinct from the
/// gray/green status shapes.
private var unreadDot: some View {
Circle()
.fill(.blue)
.frame(width: Metrics.unreadDotSize, height: Metrics.unreadDotSize)
.fill(DS.Palette.accent)
.frame(width: DS.Space.sm8, height: DS.Space.sm8)
.accessibilityLabel(ScreenCopy.unreadLabel)
}
/// Resolve the row's status to one `DisplayStatus`. Exited is a terminal
/// fact (top precedence); otherwise the VM's badge priority stands
/// (pending outranks the live status). No VM logic re-implemented here.
private var displayStatus: DisplayStatus {
if row.info.exited { return .exited }
switch row.indicator {
case .pendingApproval: return .pendingApproval
case .status(let status): return DisplayStatus(status)
}
}
/// Sanitized OSC title first (T-iOS-23, mirrors web autoTitle precedence,
/// public/tabs.ts:570), else the cwd-derived name. Both are server-supplied
/// display text (untrusted verbatim Text only).
@@ -233,36 +324,14 @@ private struct SessionRowView: View {
return URL(fileURLWithPath: cwd).lastPathComponent
}
/// Client count + `cols×rows`, rendered mono-tabular via `.dsMetaText()`.
/// The exited state is conveyed by the badge + section + dimming, so it is
/// not duplicated here.
private var meta: String {
var parts = [
[
ScreenCopy.clientCount(row.info.clientCount),
"\(row.info.cols)×\(row.info.rows)",
]
if row.info.exited {
parts.append(ScreenCopy.exitedTag)
}
return parts.joined(separator: " · ")
}
private static func dotColor(_ status: ClaudeStatus) -> Color {
switch status {
case .working: return .green
case .waiting: return .orange
case .idle: return .blue
case .stuck: return .red
case .unknown: return .gray
}
}
private enum Metrics {
static let rowSpacing: CGFloat = 10
static let rowInnerSpacing: CGFloat = 3
static let indicatorWidth: CGFloat = 18
static let dotSize: CGFloat = 10
static let dotTopPadding: CGFloat = 5
static let exitedOpacity = 0.55
static let titleSpacing: CGFloat = 6
static let unreadDotSize: CGFloat = 8
].joined(separator: " · ")
}
}
@@ -279,9 +348,9 @@ private enum ScreenCopy {
static let noSessionsTitle = "主机上没有运行中的会话"
static let noSessionsHint = "新建一个会话开始工作;关掉 App 后会话仍会在主机上继续跑。"
static let unknownDirectory = "未知目录"
static let exitedTag = "已退出"
static let pendingBadgeLabel = "等待审批"
static let unreadLabel = "有新输出"
static let activeSection = "运行中"
static let exitedSection = "已结束"
static func clientCount(_ count: Int) -> String {
"\(count) 台设备在看"