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:
@@ -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 claudeIcon(public/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 {
|
||||
|
||||
Reference in New Issue
Block a user