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

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