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

@@ -17,13 +17,13 @@ struct DiffScreen: View {
@State private var viewModel: DiffViewModel
private enum Metrics {
static let pickerHorizontalPadding: CGFloat = 16
static let pickerVerticalPadding: CGFloat = 8
static let lineVerticalInset: CGFloat = 1
static let lineHorizontalInset: CGFloat = 12
static let fileHeaderTopInset: CGFloat = 14
static let statTagSpacing: CGFloat = 8
static let lineBackgroundOpacity = 0.12
/// Faint per-line tint alpha for added/removed/hunk rows. This is a
/// **code-diff syntax constant** (like terminal/xterm colors), NOT a
/// card/status design token the frozen `DS.Opacity` scale
/// (stale/exited/pressed) has no semantic slot for a syntax highlight
/// fill. The tint HUE always comes from `DS.Palette` (below); only this
/// scanning-aid alpha is diff-local.
static let lineTintOpacity = 0.12
}
/// T-iOS-26 `(endpoint, path)` +
@@ -41,8 +41,8 @@ struct DiffScreen: View {
var body: some View {
VStack(spacing: 0) {
scopePicker
.padding(.horizontal, Metrics.pickerHorizontalPadding)
.padding(.vertical, Metrics.pickerVerticalPadding)
.padding(.horizontal, DS.Space.lg16)
.padding(.vertical, DS.Space.sm8)
content
}
.navigationTitle(DiffCopy.title)
@@ -104,6 +104,7 @@ struct DiffScreen: View {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
.tint(DS.Palette.accent)
}
}
@@ -136,8 +137,8 @@ struct DiffScreen: View {
private var truncatedBanner: some View {
Label(DiffCopy.truncatedBanner, systemImage: "scissors")
.font(.footnote)
.foregroundStyle(.orange)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
}
@ViewBuilder private func rowView(_ row: DiffRow) -> some View {
@@ -146,13 +147,13 @@ struct DiffScreen: View {
fileHeaderRow(header)
case .binaryNotice:
Text(DiffCopy.binaryFile)
.font(.caption.italic())
.foregroundStyle(.secondary)
.font(DS.Typography.caption.italic())
.foregroundStyle(DS.Palette.textSecondary)
.listRowSeparator(.hidden)
case .hunkHeader(let header):
diffTextRow(
header, color: .blue,
background: Color.blue.opacity(Metrics.lineBackgroundOpacity)
header, color: DS.Palette.accent,
background: DS.Palette.accent.opacity(Metrics.lineTintOpacity)
)
case .line(let kind, let text):
diffTextRow(
@@ -162,24 +163,25 @@ struct DiffScreen: View {
}
private func fileHeaderRow(_ header: DiffFileHeader) -> some View {
HStack(spacing: Metrics.statTagSpacing) {
// verbatim +
HStack(spacing: DS.Space.sm8) {
// verbatim +
Text(verbatim: header.pathLabel)
.font(.footnote.weight(.semibold).monospaced())
.font(DS.Typography.mono(.footnote).weight(.semibold))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
.truncationMode(.middle)
Spacer(minLength: 0)
Text(verbatim: "+\(header.added)")
.font(.caption.monospacedDigit())
.foregroundStyle(.green)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.statusWorking)
Text(verbatim: "-\(header.removed)")
.font(.caption.monospacedDigit())
.foregroundStyle(.red)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.statusStuck)
Text(DiffStatusStyle.label(for: header.status))
.font(.caption2)
.font(DS.Typography.caption)
.foregroundStyle(DiffStatusStyle.color(for: header.status))
}
.padding(.top, Metrics.fileHeaderTopInset)
.padding(.top, DS.Space.lg16)
.accessibilityElement(children: .combine)
}
@@ -187,7 +189,7 @@ struct DiffScreen: View {
/// single-line tail truncation (read-only skim view; no wrapping blob).
private func diffTextRow(_ text: String, color: Color, background: Color) -> some View {
Text(verbatim: text)
.font(.caption.monospaced())
.font(DS.Typography.mono(.caption))
.foregroundStyle(color)
.lineLimit(1)
.truncationMode(.tail)
@@ -195,30 +197,32 @@ struct DiffScreen: View {
.listRowBackground(background)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(
top: Metrics.lineVerticalInset,
leading: Metrics.lineHorizontalInset,
bottom: Metrics.lineVerticalInset,
trailing: Metrics.lineHorizontalInset
top: DS.Space.xs2,
leading: DS.Space.md12,
bottom: DS.Space.xs2,
trailing: DS.Space.md12
))
}
// MARK: - kind kind .context
// DS.Paletteadded=working 绿 / removed=stuck /
// hunk=accent App alpha diff
static func lineColor(_ kind: DiffLineKind) -> Color {
switch kind {
case .added: return .green
case .removed: return .red
case .context: return .primary
case .hunk: return .blue
case .meta: return .secondary
case .added: return DS.Palette.statusWorking
case .removed: return DS.Palette.statusStuck
case .context: return DS.Palette.textPrimary
case .hunk: return DS.Palette.accent
case .meta: return DS.Palette.textSecondary
}
}
static func lineBackground(_ kind: DiffLineKind) -> Color {
switch kind {
case .added: return .green.opacity(Metrics.lineBackgroundOpacity)
case .removed: return .red.opacity(Metrics.lineBackgroundOpacity)
case .hunk: return .blue.opacity(Metrics.lineBackgroundOpacity)
case .added: return DS.Palette.statusWorking.opacity(Metrics.lineTintOpacity)
case .removed: return DS.Palette.statusStuck.opacity(Metrics.lineTintOpacity)
case .hunk: return DS.Palette.accent.opacity(Metrics.lineTintOpacity)
case .context, .meta: return .clear
}
}
@@ -240,10 +244,10 @@ enum DiffStatusStyle {
static func color(for status: DiffFileStatus) -> Color {
switch status {
case .added, .untracked: return .green
case .deleted: return .red
case .renamed: return .blue
case .modified, .binary: return .secondary
case .added, .untracked: return DS.Palette.statusWorking
case .deleted: return DS.Palette.statusStuck
case .renamed: return DS.Palette.accent
case .modified, .binary: return DS.Palette.textSecondary
}
}
}

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)。可改用手输地址。"
}

View File

@@ -15,8 +15,7 @@ struct ProjectDetailScreen: View {
@State private var isDiffPresented = false
private enum Metrics {
static let headerSpacing: CGFloat = 4
static let chipSpacing: CGFloat = 6
/// CLAUDE.md token
static let claudeMdLineLimit = 40
}
@@ -68,6 +67,7 @@ struct ProjectDetailScreen: View {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
.tint(DS.Palette.accent)
}
}
@@ -102,29 +102,29 @@ struct ProjectDetailScreen: View {
private func headerSection(_ detail: ProjectDetail) -> some View {
Section {
VStack(alignment: .leading, spacing: Metrics.headerSpacing) {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
Text(verbatim: detail.name)
.font(.headline)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
// verbatim +
Text(verbatim: detail.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
HStack(spacing: Metrics.chipSpacing) {
HStack(spacing: DS.Space.sm8) {
if let branch = detail.branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(.caption)
.foregroundStyle(.secondary)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
if detail.dirty == true {
Text(ProjectsCopy.dirtyBadge)
.font(.caption2)
.foregroundStyle(.orange)
DirtyBadge()
}
}
}
@@ -134,13 +134,16 @@ struct ProjectDetailScreen: View {
private func actionsSection(_ detail: ProjectDetail) -> some View {
Section {
Button {
DS.Haptics.selection()
onOpenClaude(detail.path)
} label: {
Label(ProjectDetailCopy.openClaude, systemImage: "terminal.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.listRowInsets(EdgeInsets())
.buttonStyle(DSButtonStyle(kind: .primary))
.listRowInsets(EdgeInsets(
top: DS.Space.sm8, leading: DS.Space.lg16,
bottom: detail.isGit ? DS.Space.xs4 : DS.Space.sm8, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
if detail.isGit {
Button {
@@ -148,6 +151,12 @@ struct ProjectDetailScreen: View {
} label: {
Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus")
}
.buttonStyle(DSButtonStyle(kind: .secondary))
.listRowInsets(EdgeInsets(
top: DS.Space.xs4, leading: DS.Space.lg16,
bottom: DS.Space.sm8, trailing: DS.Space.lg16
))
.listRowBackground(Color.clear)
}
}
}
@@ -156,8 +165,8 @@ struct ProjectDetailScreen: View {
Section(ProjectDetailCopy.sessionsHeader) {
if sessions.isEmpty {
Text(ProjectDetailCopy.noSessions)
.font(.footnote)
.foregroundStyle(.secondary)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
} else {
ForEach(sessions, id: \.id) { session in
sessionRow(session)
@@ -167,37 +176,29 @@ struct ProjectDetailScreen: View {
}
private func sessionRow(_ session: ProjectSessionRef) -> some View {
HStack(spacing: Metrics.chipSpacing) {
Text(verbatim: Self.statusGlyph(session.status))
HStack(spacing: DS.Space.sm8) {
// = + + VoiceOver DS 退
StatusBadge(status: session.exited ? .exited : DisplayStatus(session.status))
// title cwd verbatim退 id
Text(verbatim: session.title ?? String(
session.id.uuidString.lowercased().prefix(8)
))
.font(DS.Typography.body)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Spacer(minLength: 0)
if session.exited {
Text(ProjectDetailCopy.sessionExited)
.font(.caption)
.foregroundStyle(.secondary)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
} else {
// tabular
Text(ProjectDetailCopy.clientCount(session.clientCount))
.font(.caption)
.foregroundStyle(.secondary)
.dsMetaText()
}
}
}
/// web claudeIconpublic/tabs.ts:74-80
static func statusGlyph(_ status: ClaudeStatus) -> String {
switch status {
case .working: return ""
case .waiting: return ""
case .idle: return ""
case .stuck: return ""
case .unknown: return ""
}
}
@ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View {
if !worktrees.isEmpty {
Section(ProjectDetailCopy.worktreesHeader) {
@@ -209,53 +210,79 @@ struct ProjectDetailScreen: View {
}
private func worktreeRow(_ worktree: WorktreeInfo) -> some View {
VStack(alignment: .leading, spacing: Metrics.headerSpacing) {
HStack(spacing: Metrics.chipSpacing) {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
HStack(spacing: DS.Space.sm8) {
Text(verbatim: worktree.branch ?? ProjectDetailCopy.detachedHead)
.font(.callout)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
if worktree.isMain {
badge(ProjectDetailCopy.worktreeMain)
TagBadge(text: ProjectDetailCopy.worktreeMain)
}
if worktree.isCurrent {
badge(ProjectDetailCopy.worktreeCurrent)
TagBadge(text: ProjectDetailCopy.worktreeCurrent)
}
if worktree.locked == true {
badge(ProjectDetailCopy.worktreeLocked)
TagBadge(text: ProjectDetailCopy.worktreeLocked)
}
}
Text(verbatim: worktree.path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
.truncationMode(.middle)
}
}
private func badge(_ text: String) -> some View {
Text(text)
.font(.caption2)
.foregroundStyle(.blue)
}
@ViewBuilder private func claudeMdSection(_ detail: ProjectDetail) -> some View {
if detail.hasClaudeMd {
Section(ProjectDetailCopy.claudeMdHeader) {
if let content = detail.claudeMd {
// verbatim +
Text(verbatim: content)
.font(.caption.monospaced())
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(Metrics.claudeMdLineLimit)
} else {
Text(ProjectDetailCopy.claudeMdPresent)
.font(.footnote)
.foregroundStyle(.secondary)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
}
}
}
// MARK: - DS token Projects/
/// + TelemetryChip `.quaternary`
struct DirtyBadge: View {
var body: some View {
Text(ProjectsCopy.dirtyBadge)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs2)
.background(.quaternary, in: Capsule())
}
}
/// worktree //accent
struct TagBadge: View {
let text: String
var body: some View {
Text(text)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.accent)
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs2)
.overlay(
Capsule().strokeBorder(DS.Palette.accent, lineWidth: DS.Stroke.hairline)
)
}
}
// MARK: - plan §4
enum ProjectDetailCopy {

View File

@@ -18,15 +18,9 @@ struct ProjectsScreen: View {
/// idiom size classiPad sheet compact
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
private enum Metrics {
static let rowSpacing: CGFloat = 2
static let chipSpacing: CGFloat = 6
// T-iPad-4 · iPad.padiPhone
static let gridSectionSpacing: CGFloat = 12
static let gridPadding: CGFloat = 16
static let gridCardVerticalPadding: CGFloat = 8
static let gridCardHorizontalPadding: CGFloat = 10
static let gridCardCornerRadius: CGFloat = 10
private enum Copy {
static let emptyNoProjectsTitle = "暂无项目"
static let emptyNoMatchTitle = "无匹配项目"
}
var body: some View {
@@ -72,10 +66,10 @@ struct ProjectsScreen: View {
List {
errorRows
if let message = viewModel.emptyStateMessage {
Text(message)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
emptyState(message)
.frame(maxWidth: .infinity)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
ForEach(viewModel.groups) { group in
groupSection(group)
@@ -89,6 +83,18 @@ struct ProjectsScreen: View {
}
}
/// symbol + friendly
private func emptyState(_ message: String) -> some View {
ContentUnavailableView {
Label(
viewModel.isSearching ? Copy.emptyNoMatchTitle : Copy.emptyNoProjectsTitle,
systemImage: viewModel.isSearching ? "magnifyingglass" : "folder"
)
} description: {
Text(message)
}
}
// MARK: - Gridregular
/// iPad / `ProjectsGridLayout.columnCount`
@@ -102,18 +108,17 @@ struct ProjectsScreen: View {
idiom: idiom
)
ScrollView {
LazyVStack(alignment: .leading, spacing: Metrics.gridSectionSpacing) {
LazyVStack(alignment: .leading, spacing: DS.Space.md12) {
errorRows
if let message = viewModel.emptyStateMessage {
Text(message)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
emptyState(message)
.frame(maxWidth: .infinity)
}
ForEach(viewModel.groups) { group in
gridSection(group, columns: columns)
}
}
.padding(Metrics.gridPadding)
.padding(DS.Space.lg16)
}
.overlay {
if !viewModel.hasLoadedOnce && viewModel.fetchErrorMessage == nil {
@@ -125,25 +130,22 @@ struct ProjectsScreen: View {
@ViewBuilder private func gridSection(_ group: ProjectGroup, columns: Int) -> some View {
let isCollapsed = viewModel.isCollapsed(group)
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
VStack(alignment: .leading, spacing: DS.Space.sm8) {
if group.kind != .flat {
groupHeader(group, isCollapsed: isCollapsed)
.padding(.top, Metrics.gridSectionSpacing)
.padding(.top, DS.Space.md12)
}
if !isCollapsed {
LazyVGrid(
columns: gridColumns(columns),
alignment: .leading,
spacing: Metrics.chipSpacing
spacing: DS.Space.sm8
) {
ForEach(group.projects, id: \.path) { project in
projectRow(project, group: group)
.padding(.vertical, Metrics.gridCardVerticalPadding)
.padding(.horizontal, Metrics.gridCardHorizontalPadding)
.background(
.quaternary,
in: RoundedRectangle(cornerRadius: Metrics.gridCardCornerRadius)
)
// = DS Cardsecondary + + md12
Card(padding: DS.Space.md12) {
projectRow(project, group: group)
}
}
}
}
@@ -153,7 +155,7 @@ struct ProjectsScreen: View {
/// `columns >= 1` `ProjectsGridLayout`
private func gridColumns(_ count: Int) -> [GridItem] {
Array(
repeating: GridItem(.flexible(), spacing: Metrics.chipSpacing),
repeating: GridItem(.flexible(), spacing: DS.Space.sm8),
count: max(count, ProjectsGridLayout.singleColumn)
)
}
@@ -170,8 +172,8 @@ struct ProjectsScreen: View {
id: \.self
) { message in
Label(message, systemImage: "exclamationmark.triangle")
.font(.footnote)
.foregroundStyle(.orange)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.statusWaiting)
.listRowSeparator(.hidden)
}
}
@@ -194,21 +196,26 @@ struct ProjectsScreen: View {
}
private func groupHeader(_ group: ProjectGroup, isCollapsed: Bool) -> some View {
HStack(spacing: Metrics.chipSpacing) {
HStack(spacing: DS.Space.sm8) {
if group.isCollapsible {
Image(systemName: isCollapsed ? "chevron.right" : "chevron.down")
.font(.caption2)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
// namespace label verbatim
Text(verbatim: group.label)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
// tabular
Text(verbatim: "\(group.projects.count)")
.foregroundStyle(.secondary)
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.textTertiary)
// web
if group.kind != .active && group.activeCount > 0 {
Text(ProjectsCopy.activeCountBadge(group.activeCount))
.font(.caption2)
.foregroundStyle(.green)
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.statusWorking)
}
Spacer(minLength: 0)
}
@@ -224,22 +231,21 @@ struct ProjectsScreen: View {
private func projectRow(_ project: ProjectInfo, group: ProjectGroup) -> some View {
NavigationLink(value: ProjectRoute(path: project.path)) {
HStack(spacing: Metrics.chipSpacing) {
HStack(spacing: DS.Space.sm8) {
favouriteButton(project)
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
VStack(alignment: .leading, spacing: DS.Space.xs2) {
Text(verbatim: ProjectGrouping.displayLabel(
name: project.name, groupKey: group.key
))
.font(.body.weight(.medium))
.font(DS.Typography.body.weight(.medium))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
projectChips(project)
}
Spacer(minLength: 0)
// DS + + VoiceOver
if ProjectGrouping.hasRunningSession(project) {
Image(systemName: "circle.fill")
.font(.caption2)
.foregroundStyle(.green)
.accessibilityLabel(ProjectsCopy.activeCountBadge(1))
StatusBadge(status: .working)
}
}
}
@@ -247,29 +253,29 @@ struct ProjectsScreen: View {
private func favouriteButton(_ project: ProjectInfo) -> some View {
Button {
DS.Haptics.selection()
Task { await viewModel.toggleFavourite(path: project.path) }
} label: {
Image(systemName: viewModel.isFavourite(project.path) ? "star.fill" : "star")
.foregroundStyle(.yellow)
let isFav = viewModel.isFavourite(project.path)
Image(systemName: isFav ? "star.fill" : "star")
.foregroundStyle(isFav ? DS.Palette.accent : DS.Palette.textTertiary)
}
.buttonStyle(.borderless) // List
}
@ViewBuilder private func projectChips(_ project: ProjectInfo) -> some View {
HStack(spacing: Metrics.chipSpacing) {
HStack(spacing: DS.Space.sm8) {
if let branch = project.branch {
Label {
Text(verbatim: branch).lineLimit(1)
} icon: {
Image(systemName: "arrow.triangle.branch")
}
.font(.caption)
.foregroundStyle(.secondary)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
if project.dirty == true {
Text(ProjectsCopy.dirtyBadge)
.font(.caption2)
.foregroundStyle(.orange)
DirtyBadge()
}
}
}

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) 台设备在看"

View File

@@ -37,10 +37,8 @@ struct TerminalScreen: View {
/// T-iPad-3 · KeyBar nil =
@State private var keyBarUserOverride: Bool?
private enum Metrics {
static let bannerHorizontalPadding: CGFloat = 12
static let bannerTopPadding: CGFloat = 8
}
/// DS.Motion.gated
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private enum Copy {
static let newSessionInCwd = "在当前目录开新会话"
@@ -67,12 +65,15 @@ struct TerminalScreen: View {
.overlay(alignment: .top) {
if let model = viewModel.bannerModel {
ReconnectBanner(model: model, onNewSession: onNewSessionInCwd)
.padding(.horizontal, Metrics.bannerHorizontalPadding)
.padding(.top, Metrics.bannerTopPadding)
.padding(.horizontal, DS.Space.md12)
.padding(.top, DS.Space.sm8)
.transition(.move(edge: .top).combined(with: .opacity))
}
}
.animation(.default, value: viewModel.bannerModel)
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: viewModel.bannerModel
)
.toolbar {
newSessionToolbarItem
keyBarToggleToolbarItem
@@ -122,6 +123,27 @@ struct TerminalScreen: View {
}
}
// MARK: - Terminal theme
/// Refined dark terminal theme (). The canvas follows the app surface
/// near-black in dark mode, so the terminal reads as part of the chrome while
/// the caret and selection use the one accent indigo. Every value routes through
/// `DS` (no literals); the glyph font is Dynamic-Type-aware SF Mono so terminal
/// text respects the user's text-size choice at launch.
private enum TerminalTheme {
@MainActor
static func apply(to terminal: TerminalView) {
terminal.font = UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
weight: .regular
)
terminal.nativeBackgroundColor = UIColor(DS.Palette.surface)
terminal.nativeForegroundColor = UIColor(DS.Palette.textPrimary)
terminal.caretColor = DS.Palette.accentUIColor()
terminal.selectedTextBackgroundColor = DS.Palette.accentUIColor()
}
}
// MARK: - SwiftTerm bridge
/// `UIViewRepresentable` around `SwiftTerm.TerminalView` (plan §3.5). The
@@ -142,6 +164,7 @@ private struct TerminalHostView: UIViewRepresentable {
func makeUIView(context: Context) -> KeyCommandTerminalView {
let terminal = KeyCommandTerminalView(frame: .zero)
terminal.terminalDelegate = context.coordinator
TerminalTheme.apply(to: terminal)
let viewModel = viewModel
terminal.onKeyCommand = { key in viewModel.send(key: key) }

View File

@@ -12,11 +12,6 @@ import WireProtocol
struct TimelineSheet: View {
let viewModel: TimelineViewModel
private enum Metrics {
static let rowSpacing: CGFloat = 10
static let iconColumnWidth: CGFloat = 24
}
var body: some View {
NavigationStack {
content
@@ -66,6 +61,7 @@ struct TimelineSheet: View {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
.tint(DS.Palette.accent)
}
}
@@ -83,21 +79,25 @@ struct TimelineSheet: View {
}
private func row(_ event: TimelineEvent) -> some View {
HStack(spacing: Metrics.rowSpacing) {
HStack(spacing: DS.Space.md12) {
// Timestamp mono/tabular so HH:mm columns line up (direction).
Text(TimelineRowFormat.timeLabel(atMs: event.at))
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.font(DS.Typography.mono(.caption))
.foregroundStyle(DS.Palette.textSecondary)
// class glyph + semantic color (frozen mapping; unit-tested).
Text(verbatim: TimelineClassStyle.glyph(for: event.class))
.font(.callout)
.font(DS.Typography.callout)
.foregroundStyle(TimelineClassStyle.color(for: event.class))
.frame(width: Metrics.iconColumnWidth)
.frame(width: DS.Space.xxl24)
// Server-derived phrase untrusted: verbatim (never
// LocalizedStringKey/Markdown) + hard single-line truncation.
Text(verbatim: event.label)
.font(.subheadline)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.vertical, DS.Space.xs2)
.accessibilityElement(children: .combine)
}
}
@@ -125,13 +125,14 @@ enum TimelineClassStyle {
}
static func color(for cls: String) -> Color {
// All from the frozen design system no raw SwiftUI colors (UX finding).
switch cls {
case "tool": return .blue
case "waiting": return .orange
case "done": return .green
case "stuck": return .red
case "user": return .purple
default: return .secondary
case "tool": return DS.Palette.timelineTool
case "waiting": return DS.Palette.statusWaiting
case "done": return DS.Palette.statusWorking
case "stuck": return DS.Palette.statusStuck
case "user": return DS.Palette.timelineUser
default: return DS.Palette.textSecondary
}
}
}