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

@@ -14,11 +14,6 @@ struct AwayDigestView: View {
let onDismiss: () -> Void
private enum Metrics {
static let spacing: CGFloat = 8
static let rowSpacing: CGFloat = 4
static let horizontalPadding: CGFloat = 14
static let verticalPadding: CGFloat = 10
static let cornerRadius: CGFloat = 12
/// Recent entries shown when expanded (newest kept the engine's
/// digest is uncapped, the banner must not be).
static let maxRecentRows = 12
@@ -48,26 +43,31 @@ struct AwayDigestView: View {
}
var body: some View {
VStack(alignment: .leading, spacing: Metrics.spacing) {
VStack(alignment: .leading, spacing: DS.Space.sm8) {
summaryRow
if isExpanded {
recentRows
}
}
.padding(.horizontal, Metrics.horizontalPadding)
.padding(.vertical, Metrics.verticalPadding)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: Metrics.cornerRadius))
.padding(.horizontal, DS.Space.md12)
.padding(.vertical, DS.Space.sm8)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
.accessibilityElement(children: .contain)
}
private var summaryRow: some View {
HStack(spacing: Metrics.spacing) {
HStack(spacing: DS.Space.sm8) {
Image(systemName: "clock.arrow.circlepath")
.foregroundStyle(.secondary)
.foregroundStyle(DS.Palette.accent)
Text(Self.summary(for: digest))
.font(.footnote.weight(.medium))
.font(DS.Typography.callout.weight(.medium))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
Spacer(minLength: Metrics.spacing)
Spacer(minLength: DS.Space.sm8)
if !isExpanded {
Button(Copy.expandTitle, systemImage: "chevron.down", action: onExpand)
.labelStyle(.iconOnly)
@@ -76,13 +76,14 @@ struct AwayDigestView: View {
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.font(.footnote)
.tint(DS.Palette.accent)
.font(DS.Typography.callout)
}
/// Newest `maxRecentRows` entries, oldestnewest. Server text (`label`) is
/// untrusted display input rendered as plain Text only.
private var recentRows: some View {
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
ForEach(
Array(digest.recent.suffix(Metrics.maxRecentRows).enumerated()),
id: \.offset
@@ -93,12 +94,13 @@ struct AwayDigestView: View {
}
private func recentRow(_ event: TimelineEvent) -> some View {
HStack(spacing: Metrics.spacing) {
HStack(spacing: DS.Space.sm8) {
Text(Self.time(of: event))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.textSecondary)
Text(event.label)
.font(.caption)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(1)
}
}

View File

@@ -35,11 +35,13 @@ struct GateBanner: View {
let gate: GateState
let onDecide: (GateState.Affordance, Int) -> Void
private enum Copy {
/// Chinese lead-in above the (web-mirrored) English tool line makes the
/// "this needs me" intent unmissable at a glance.
static let heading = "需要你的确认"
}
private enum Metrics {
static let spacing: CGFloat = 8
static let horizontalPadding: CGFloat = 14
static let verticalPadding: CGFloat = 10
static let cornerRadius: CGFloat = 12
static let messageLineLimit = 2
}
@@ -50,38 +52,57 @@ struct GateBanner: View {
"Claude wants to use \(gate.detail ?? "a tool")"
}
/// A prominent Card (the gate is THE core action approve/reject a shell
/// command). An amber "needs me" badge heads it, the tool line reads big and
/// clear, and full-width DS buttons give unmissable tap targets.
var body: some View {
VStack(alignment: .leading, spacing: Metrics.spacing) {
Text(Self.message(for: gate))
.font(.footnote.weight(.semibold))
.lineLimit(Metrics.messageLineLimit)
HStack(spacing: Metrics.spacing) {
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
decisionButton(spec)
}
Card {
VStack(alignment: .leading, spacing: DS.Space.md12) {
header
Text(Self.message(for: gate))
.font(DS.Typography.body.weight(.semibold))
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(Metrics.messageLineLimit)
.fixedSize(horizontal: false, vertical: true)
buttonRow
}
}
.padding(.horizontal, Metrics.horizontalPadding)
.padding(.vertical, Metrics.verticalPadding)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: Metrics.cornerRadius))
.accessibilityElement(children: .contain)
}
private var header: some View {
HStack(spacing: DS.Space.sm8) {
StatusBadge(status: .pendingApproval)
Text(Copy.heading)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
private var buttonRow: some View {
HStack(spacing: DS.Space.sm8) {
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
decisionButton(spec)
}
}
}
private func decisionButton(_ spec: GateChoiceSpec) -> some View {
Button(spec.label) {
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
}
.buttonStyle(DSButtonStyle(kind: kind(for: spec.affordance)))
.accessibilityIdentifier("gate.decision.\(spec.affordance)")
.buttonStyle(.borderedProminent)
.controlSize(.small)
.tint(tint(for: spec.affordance))
}
private func tint(for affordance: GateState.Affordance) -> Color {
/// Approve reads as the accent-filled primary, Reject as the destructive
/// red; Keep Planning (never on a tool gate, but mapped for completeness)
/// is the tinted secondary.
private func kind(for affordance: GateState.Affordance) -> DSButtonStyle.Kind {
switch affordance {
case .approve, .approveAuto, .approveReview: return .green
case .reject: return .red
case .keepPlanning: return .indigo
case .approve, .approveAuto, .approveReview: return .primary
case .reject: return .destructive
case .keepPlanning: return .secondary
}
}
}

View File

@@ -95,12 +95,17 @@ enum KeyBarLayout {
]
}
/// Layout constants all composed from the frozen `DS` scale (no literals).
/// UIKit reads the same CGFloat tokens the SwiftUI chrome uses, so the key bar
/// stays on-grid with the rest of the app.
private enum KeyBarMetrics {
static let barHeight: CGFloat = 52
static let buttonSpacing: CGFloat = 6
static let contentInset: CGFloat = 8
/// A hit-target-tall row plus a little breathing room (44 + 8).
static let barHeight: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8
static let buttonSpacing: CGFloat = DS.Space.xs4
static let contentInset: CGFloat = DS.Space.sm8
static let buttonInsets = NSDirectionalEdgeInsets(
top: 4, leading: 9, bottom: 4, trailing: 9
top: DS.Space.xs4, leading: DS.Space.md12,
bottom: DS.Space.xs4, trailing: DS.Space.md12
)
}
@@ -172,7 +177,13 @@ final class KeyBarView: UIInputView {
}
private func makeButton(for spec: KeyBarButtonSpec) -> UIButton {
var config: UIButton.Configuration = spec.isPrimary ? .tinted() : .plain()
// Keycap look: primary (Esc) wears the accent tint, the rest a subtle
// gray fill; both get an sm8 rounded corner. (`.secondaryLabel` is the
// UIKit twin of DS.Palette.textSecondary the DS UIColor surface only
// vends the accent, so native system labels stand in at this boundary.)
var config: UIButton.Configuration = spec.isPrimary ? .tinted() : .gray()
config.cornerStyle = .fixed
config.background.cornerRadius = DS.Radius.sm8
var label = AttributedString(spec.label)
label.font = UIFont.monospacedSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .footnote).pointSize,
@@ -187,7 +198,14 @@ final class KeyBarView: UIInputView {
config.contentInsets = KeyBarMetrics.buttonInsets
let button = UIButton(configuration: config)
if spec.isPrimary {
button.tintColor = DS.Palette.accentUIColor()
}
button.accessibilityLabel = spec.title
// HIG minimum touch target regardless of glyph width.
button.heightAnchor
.constraint(greaterThanOrEqualToConstant: DS.Layout.minHitTarget)
.isActive = true
button.addAction(
UIAction { [weak self] _ in self?.onKey?(spec.key) },
for: .touchUpInside

View File

@@ -16,40 +16,69 @@ struct PlanGateSheet: View {
let gate: GateState
let onDecide: (GateState.Affordance, Int) -> Void
private enum Metrics {
static let spacing: CGFloat = 12
static let padding: CGFloat = 20
static let captionSpacing: CGFloat = 2
private enum Copy {
/// Chinese lead-in above the (web-mirrored) English question.
static let heading = "计划已就绪"
}
var body: some View {
VStack(alignment: .leading, spacing: Metrics.spacing) {
Text(Self.title)
.font(.headline)
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
choiceButton(spec)
VStack(alignment: .leading, spacing: DS.Space.lg16) {
VStack(alignment: .leading, spacing: DS.Space.xs4) {
Text(Copy.heading)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
Text(Self.title)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
.fixedSize(horizontal: false, vertical: true)
}
ScrollView {
VStack(spacing: DS.Space.sm8) {
ForEach(GateChoiceSpec.specs(for: gate), id: \.affordance) { spec in
choiceButton(spec)
}
}
}
.scrollBounceBehavior(.basedOnSize)
}
.padding(Metrics.padding)
.padding(DS.Space.xl20)
.frame(maxWidth: .infinity, alignment: .leading)
.presentationDetents([.medium])
.accessibilityElement(children: .contain)
}
/// One full-width choice Card: semantic icon + label + the plain-language
/// caption of what the wire mode actually does + a disclosure chevron. The
/// whole card is the (large) tap target.
private func choiceButton(_ spec: GateChoiceSpec) -> some View {
Button {
onDecide(spec.affordance, gate.epoch) // epoch of the RENDERED gate
} label: {
VStack(alignment: .leading, spacing: Metrics.captionSpacing) {
Text(spec.label)
.font(.body.weight(.medium))
Text(Self.caption(for: spec.affordance))
.font(.caption)
.foregroundStyle(.secondary)
Card(padding: DS.Space.md12) {
HStack(spacing: DS.Space.md12) {
Image(systemName: symbol(for: spec.affordance))
.font(DS.Typography.title)
.foregroundStyle(iconColor(for: spec.affordance))
.frame(width: DS.Space.xxl24)
VStack(alignment: .leading, spacing: DS.Space.xs2) {
Text(spec.label)
.font(DS.Typography.body.weight(.semibold))
.foregroundStyle(DS.Palette.textPrimary)
Text(Self.caption(for: spec.affordance))
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: DS.Space.sm8)
Image(systemName: "chevron.right")
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textTertiary)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.buttonStyle(.bordered)
.tint(tint(for: spec.affordance))
.buttonStyle(.plain)
.accessibilityIdentifier("plan.decision.\(spec.affordance)")
}
/// Secondary line under each choice: what the wire mode actually does.
@@ -63,11 +92,27 @@ struct PlanGateSheet: View {
}
}
private func tint(for affordance: GateState.Affordance) -> Color {
/// Distinct SF Symbol per choice (shape carries meaning, not just color):
/// Auto = a bolt (fast, hands-off), Review = a checkmark (approve, confirm
/// each edit), Keep Planning = a pencil (stay drafting).
private func symbol(for affordance: GateState.Affordance) -> String {
switch affordance {
case .approve, .approveAuto, .approveReview: return .green
case .reject: return .red
case .keepPlanning: return .indigo
case .approveAuto: return "bolt.fill"
case .approveReview: return "checkmark.circle"
case .keepPlanning: return "pencil.and.outline"
case .approve: return "checkmark.circle"
case .reject: return "xmark.circle"
}
}
/// Both approve paths wear the accent (this is the encouraged action); Keep
/// Planning is a quiet secondary; a bare reject (tool-shaped, unused here)
/// stays the stuck red.
private func iconColor(for affordance: GateState.Affordance) -> Color {
switch affordance {
case .approveAuto, .approveReview, .approve: return DS.Palette.accent
case .keepPlanning: return DS.Palette.textSecondary
case .reject: return DS.Palette.statusStuck
}
}
}

View File

@@ -24,12 +24,6 @@ struct QuickReplyBar: View {
static let managePhrases = "管理常用语"
}
private enum Metrics {
static let chipSpacing: CGFloat = 6
static let rowHorizontalPadding: CGFloat = 8
static let rowVerticalPadding: CGFloat = 6
}
let terminalViewModel: TerminalViewModel
let gateViewModel: GateViewModel
let store: QuickReplyStore
@@ -62,16 +56,19 @@ struct QuickReplyBar: View {
private var chipRow: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Metrics.chipSpacing) {
HStack(spacing: DS.Space.sm8) {
ForEach(store.allChips) { chip in
chipButton(chip)
}
managePhrasesButton
}
.padding(.horizontal, Metrics.rowHorizontalPadding)
.padding(.vertical, Metrics.rowVerticalPadding)
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs4)
}
.background(.thinMaterial, in: Capsule())
.background(.regularMaterial, in: Capsule())
.overlay(
Capsule().strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
@@ -82,11 +79,14 @@ struct QuickReplyBar: View {
// Text(verbatim:) user/label strings render as inert text, never
// LocalizedStringKey/Markdown (SEC-L3 mirror; T-iOS-23 convention).
Text(verbatim: chip.label)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.accent)
.lineLimit(1)
.padding(.horizontal, DS.Space.md12)
.frame(minHeight: DS.Layout.minHitTarget)
.background(.quaternary, in: Capsule())
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.small)
.buttonStyle(.plain)
}
private var managePhrasesButton: some View {
@@ -94,10 +94,12 @@ struct QuickReplyBar: View {
isPanelPresented = true
} label: {
Image(systemName: "plus")
.font(DS.Typography.callout.weight(.semibold))
.foregroundStyle(DS.Palette.accent)
.frame(width: DS.Layout.minHitTarget, height: DS.Layout.minHitTarget)
.background(.quaternary, in: Capsule())
}
.buttonStyle(.bordered)
.buttonBorderShape(.capsule)
.controlSize(.small)
.buttonStyle(.plain)
.accessibilityLabel(Copy.managePhrases)
}
}
@@ -155,7 +157,8 @@ struct QuickReplyPanel: View {
Section(Copy.customSection) {
if store.userChips.isEmpty {
Text(Copy.emptyState)
.foregroundStyle(.secondary)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textSecondary)
} else {
ForEach(store.userChips) { chip in
Button {
@@ -163,7 +166,7 @@ struct QuickReplyPanel: View {
} label: {
chipRow(chip)
}
.tint(.primary)
.tint(DS.Palette.textPrimary)
}
.onDelete { offsets in
// Resolve ids FIRST removing while iterating offsets
@@ -181,15 +184,16 @@ struct QuickReplyPanel: View {
}
private func chipRow(_ chip: QuickReplyChip) -> some View {
VStack(alignment: .leading) {
VStack(alignment: .leading, spacing: DS.Space.xs2) {
// Text(verbatim:) stored strings are boundary data (SEC-L3 mirror).
Text(verbatim: chip.label)
.font(DS.Typography.body)
.lineLimit(1)
Text(verbatim: chip.appendEnter
? chip.text + Copy.enterSuffixSymbol
: chip.text)
.font(.caption)
.foregroundStyle(.secondary)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
.lineLimit(1)
}
}

View File

@@ -25,42 +25,50 @@ struct ReconnectBanner: View {
/// asks the user to change a knob first, not to spawn again.
var onNewSession: (@MainActor () -> Void)? = nil
private enum Metrics {
static let contentSpacing: CGFloat = 8
static let horizontalPadding: CGFloat = 14
static let verticalPadding: CGFloat = 8
static let backgroundOpacity = 0.9
}
private enum Copy {
static let newSession = "开新会话"
}
/// + +
/// GROUP BRIEF
var body: some View {
HStack(spacing: Metrics.contentSpacing) {
if isSpinning {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: iconName)
}
HStack(spacing: DS.Space.sm8) {
leadingIndicator
Text(text)
.font(.footnote)
.font(DS.Typography.callout)
.foregroundStyle(DS.Palette.textPrimary)
.multilineTextAlignment(.leading)
if let onNewSession, Self.isNewSessionActionAvailable(for: model) {
Button(Copy.newSession) { onNewSession() }
.font(.footnote.bold())
.font(DS.Typography.callout.weight(.semibold))
.foregroundStyle(DS.Palette.accent)
.buttonStyle(.plain)
.accessibilityIdentifier("terminal.exitNewSessionButton")
}
}
.padding(.horizontal, Metrics.horizontalPadding)
.padding(.vertical, Metrics.verticalPadding)
.background(tint.opacity(Metrics.backgroundOpacity), in: Capsule())
.foregroundStyle(.white)
.padding(.horizontal, DS.Space.lg16)
.padding(.vertical, DS.Space.sm8)
.background(.regularMaterial, in: Capsule())
.overlay(
Capsule().strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
.accessibilityElement(children: .combine)
}
/// Spinner while a connection is in flight, otherwise the state's icon
/// both painted in the semantic indicator color (shape + color, never color
/// alone).
@ViewBuilder private var leadingIndicator: some View {
if isSpinning {
ProgressView()
.controlSize(.small)
.tint(indicatorColor)
} else {
Image(systemName: iconName)
.foregroundStyle(indicatorColor)
}
}
/// The new-session affordance appears on the `.exited` banner only
/// pure predicate so the T-iOS-29 tests pin the rule.
static func isNewSessionActionAvailable(for model: Model) -> Bool {
@@ -83,12 +91,15 @@ struct ReconnectBanner: View {
}
}
private var tint: Color {
/// Semantic indicator color (DS only): connecting is quiet gray, reconnecting
/// is the calm accent (not an orange alarm), failed is the stuck red, exited
/// is dimmed secondary. Paired with a distinct icon so it is never color-only.
private var indicatorColor: Color {
switch model {
case .connecting: return .gray
case .reconnecting: return .orange
case .failed: return .red
case .exited: return .indigo
case .connecting: return DS.Palette.textSecondary
case .reconnecting: return DS.Palette.accent
case .failed: return DS.Palette.statusStuck
case .exited: return DS.Palette.statusExited
}
}

View File

@@ -318,38 +318,47 @@ struct SessionThumbnailView: View {
let request: SessionThumbnailRequest
let pipeline: SessionThumbnailPipeline
@State private var image: SessionThumbnailImage?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// `SessionThumbnailRenderer.snapshotTargetWidth`=2×88
/// 2x token
private enum Metrics {
static let width: CGFloat = 88
static let height: CGFloat = 56
static let cornerRadius: CGFloat = 6
/// web public/preview-grid.ts PREVIEW_THEME
static let placeholderBackground = Color(
red: 14 / 255, green: 15 / 255, blue: 19 / 255
)
}
var body: some View {
content
.frame(width: Metrics.width, height: Metrics.height)
.clipShape(RoundedRectangle(cornerRadius: Metrics.cornerRadius))
.accessibilityLabel(SessionThumbnailCopy.thumbnailLabel)
.task(id: request.key) {
image = await pipeline.thumbnail(for: request)
ZStack {
placeholder
if let snapshot = image?.uiImage {
Image(uiImage: snapshot)
.resizable()
.aspectRatio(contentMode: .fill)
.transition(.opacity)
}
}
.frame(width: Metrics.width, height: Metrics.height)
.clipShape(RoundedRectangle(cornerRadius: DS.Radius.sm8))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.sm8)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
// Reduce Motion
.animation(DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion), value: image)
.accessibilityLabel(SessionThumbnailCopy.thumbnailLabel)
.task(id: request.key) {
image = await pipeline.thumbnail(for: request)
}
}
@ViewBuilder private var content: some View {
if let snapshot = image?.uiImage {
Image(uiImage: snapshot)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
ZStack {
Metrics.placeholderBackground
Image(systemName: "terminal")
.foregroundStyle(.secondary)
}
/// / / + overlay +
/// DS direction: "not a raw black rect"
private var placeholder: some View {
ZStack {
DS.Palette.card
Image(systemName: "terminal")
.imageScale(.large)
.foregroundStyle(DS.Palette.textTertiary)
}
}
}

View File

@@ -71,50 +71,52 @@ struct TelemetryChips: View {
let model: Model
/// Composed from the frozen `TelemetryChip` primitive each chip is
/// mono-tabular, greys itself out when `isStale`, and the context chip goes
/// amber over the warn threshold. The PR chip becomes a tappable `Link`
/// only when the VM handed us an https URL (SEC-L5 boundary stays in Model).
var body: some View {
HStack(spacing: Metrics.chipSpacing) {
HStack(spacing: DS.Space.xs4) {
if let contextText = model.contextText {
Text(contextText)
.foregroundStyle(model.isContextWarning ? Color.orange : Color.secondary)
TelemetryChip(
systemImage: Symbols.context,
text: contextText,
isStale: model.isStale,
isWarning: model.isContextWarning
)
}
if let costText = model.costText {
chip(costText)
TelemetryChip(text: costText, isStale: model.isStale)
}
if let modelName = model.modelName {
chip(modelName)
TelemetryChip(
systemImage: Symbols.model, text: modelName, isStale: model.isStale
)
}
prBadge
}
.font(.caption2.monospacedDigit())
.lineLimit(1)
.opacity(model.isStale ? Metrics.staleOpacity : 1)
.grayscale(model.isStale ? 1 : 0)
}
@ViewBuilder private var prBadge: some View {
if let prText = model.prText {
let label = model.prReviewState.map { "\(prText) · \($0)" } ?? prText
let chip = TelemetryChip(
systemImage: Symbols.pr, text: label, isStale: model.isStale
)
if let url = model.prURL {
Link(label, destination: url)
Link(destination: url) { chip }
} else {
chip(label)
chip
}
}
}
private func chip(_ text: String) -> some View {
Text(text)
.foregroundStyle(.secondary)
.padding(.horizontal, Metrics.chipHorizontalPadding)
.background(.quaternary, in: Capsule())
}
// MARK: - Chip icons (SF Symbols, no magic numbers/colors DS owns those)
// MARK: - Named constants (no magic numbers, plan §4)
private enum Metrics {
static let chipSpacing: CGFloat = 6
static let chipHorizontalPadding: CGFloat = 5
static let staleOpacity = 0.45
private enum Symbols {
static let context = "gauge.medium"
static let model = "cpu"
static let pr = "arrow.triangle.branch"
}
private enum Format {

View File

@@ -0,0 +1,222 @@
import SwiftUI
import WireProtocol
/// # Primitives reusable SwiftUI building blocks (FROZEN public surface)
///
/// The four component groups compose these by exact name. Each pulls ALL of its
/// constants from `DS`/`StatusStyle`/`DS.Typography` no inline magic. Every
/// primitive ships a `#Preview`.
// MARK: - StatusBadge
/// Color + distinct SF Symbol (+ optional Chinese label) for one status. The
/// VoiceOver label is baked in from `StatusStyle`, so status is conveyed by
/// shape, color AND speech. The symbol scales with Dynamic Type.
struct StatusBadge: View {
let status: DisplayStatus
/// Show the Chinese word next to the symbol (list rows usually don't).
var showsLabel: Bool = false
/// Convenience init from the wire enum.
init(status: DisplayStatus, showsLabel: Bool = false) {
self.status = status
self.showsLabel = showsLabel
}
init(claude: ClaudeStatus, showsLabel: Bool = false) {
self.init(status: DisplayStatus(claude), showsLabel: showsLabel)
}
private var style: StatusStyle { StatusStyle.style(for: status) }
var body: some View {
HStack(spacing: DS.Space.xs4) {
Image(systemName: style.symbolName)
.foregroundStyle(style.color)
.imageScale(.medium)
if showsLabel {
Text(style.label)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel(style.label)
}
}
// MARK: - TelemetryChip
/// One pill of monospaced-tabular telemetry (context %, $cost, model, PR).
/// Greys out + desaturates when `isStale`; a `isWarning` chip switches to the
/// waiting/amber semantic color for over-threshold context.
struct TelemetryChip: View {
/// Optional leading SF Symbol.
var systemImage: String? = nil
let text: String
var isStale: Bool = false
var isWarning: Bool = false
var body: some View {
HStack(spacing: DS.Space.xs2) {
if let systemImage {
Image(systemName: systemImage)
}
Text(text)
}
.font(DS.Typography.mono(.caption2))
.lineLimit(1)
.foregroundStyle(isWarning ? DS.Palette.statusWaiting : DS.Palette.textSecondary)
.padding(.horizontal, DS.Space.sm8)
.padding(.vertical, DS.Space.xs2)
.background(.quaternary, in: Capsule())
.opacity(isStale ? DS.Opacity.stale : 1)
.grayscale(isStale ? 1 : 0)
}
}
// MARK: - Card
/// Standard card container: card surface + hairline stroke + `md12` radius +
/// standard padding. The uniform card spec for rows, grid cells and panels.
struct Card<Content: View>: View {
/// Inner padding (defaults to `md12`; pass `sm8` for tight rows).
var padding: CGFloat = DS.Space.md12
@ViewBuilder var content: () -> Content
init(padding: CGFloat = DS.Space.md12, @ViewBuilder content: @escaping () -> Content) {
self.padding = padding
self.content = content
}
var body: some View {
content()
.padding(padding)
.background(DS.Palette.card, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
}
}
// MARK: - SectionHeader
/// A small, secondary section label (Chinese copy passed verbatim by callers).
struct SectionHeader: View {
let title: String
var body: some View {
Text(title)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
}
// MARK: - DSButtonStyle
/// The App's button style. `primary` = accent-filled, `secondary` = tinted
/// outline, `destructive` = red-filled. Always `minHitTarget` tall, full
/// width, `md12` radius. Press feedback honors Reduce Motion.
struct DSButtonStyle: ButtonStyle {
enum Kind { case primary, secondary, destructive }
var kind: Kind = .primary
func makeBody(configuration: Configuration) -> some View {
DSButtonBody(kind: kind, configuration: configuration)
}
/// Nested view so we can read `@Environment` (a `ButtonStyle` cannot).
/// Must be as accessible as `DSButtonStyle` (opaque `makeBody` requirement).
struct DSButtonBody: View {
let kind: Kind
let configuration: Configuration
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.isEnabled) private var isEnabled
var body: some View {
configuration.label
.font(DS.Typography.body.weight(.semibold))
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
.foregroundStyle(foreground)
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
.overlay(border)
.opacity(opacity)
.animation(
DS.Motion.gated(DS.Motion.fast, reduceMotion: reduceMotion),
value: configuration.isPressed
)
}
private var foreground: Color {
switch kind {
case .primary, .destructive: return .white
case .secondary: return DS.Palette.accent
}
}
private var background: Color {
switch kind {
case .primary: return DS.Palette.accent
case .destructive: return DS.Palette.statusStuck
case .secondary: return DS.Palette.card
}
}
@ViewBuilder private var border: some View {
if kind == .secondary {
RoundedRectangle(cornerRadius: DS.Radius.md12)
.strokeBorder(DS.Palette.accent, lineWidth: DS.Stroke.hairline)
}
}
private var opacity: Double {
if !isEnabled { return DS.Opacity.pressed }
return configuration.isPressed ? DS.Opacity.pressed : 1
}
}
}
// MARK: - Previews
#Preview("StatusBadge") {
VStack(alignment: .leading, spacing: DS.Space.md12) {
ForEach(DisplayStatus.allCases, id: \.self) { status in
StatusBadge(status: status, showsLabel: true)
}
}
.padding(DS.Space.lg16)
}
#Preview("TelemetryChip") {
HStack(spacing: DS.Space.sm8) {
TelemetryChip(text: "ctx 92%", isWarning: true)
TelemetryChip(text: "$0.1234")
TelemetryChip(systemImage: "cpu", text: "opus")
TelemetryChip(text: "PR #7", isStale: true)
}
.padding(DS.Space.lg16)
}
#Preview("Card") {
Card {
VStack(alignment: .leading, spacing: DS.Space.sm8) {
SectionHeader(title: "会话")
Text(verbatim: "web-terminal")
.font(DS.Typography.headline)
Text(verbatim: "2 台设备在看 · 161×50")
.dsMetaText()
}
}
.padding(DS.Space.lg16)
}
#Preview("DSButtonStyle") {
VStack(spacing: DS.Space.md12) {
Button("新建会话") {}.buttonStyle(DSButtonStyle(kind: .primary))
Button("继续上次会话") {}.buttonStyle(DSButtonStyle(kind: .secondary))
Button("结束会话") {}.buttonStyle(DSButtonStyle(kind: .destructive))
}
.tint(DS.Palette.accent)
.padding(DS.Space.lg16)
}

View File

@@ -0,0 +1,86 @@
import SwiftUI
import WireProtocol
/// # Status visuals the SINGLE source (FROZEN public surface)
///
/// Every place that shows a session's Claude-Code status (list rows, badges,
/// thumbnails, project-detail rows, banners) resolves it through here, so the
/// color + shape + label stay identical everywhere. Status is expressed as
/// **color AND a distinct SF Symbol** never color alone (accessibility;
/// color-blind users read the shape). Labels are Chinese for VoiceOver + UI.
/// The seven visual states a status indicator can show. A superset of the wire
/// `ClaudeStatus` (working/waiting/idle/unknown/stuck) plus two App-layer
/// emphasis states:
/// - `pendingApproval` a tool/plan gate is held server-side ("needs me").
/// Outranks status; mirrors `SessionListViewModel.Indicator.pendingApproval`
/// (this type does NOT re-implement that priority callers decide when to
/// use it; here it's only its visual identity).
/// - `exited` the session is over (read-only).
enum DisplayStatus: CaseIterable, Sendable, Equatable {
case working
case waiting
case idle
case stuck
case unknown
case pendingApproval
case exited
/// Bridge from the wire enum. Pure no pending/exited emphasis (callers
/// supply those explicitly, matching the VM's own indicator priority).
init(_ claude: ClaudeStatus) {
switch claude {
case .working: self = .working
case .waiting: self = .waiting
case .idle: self = .idle
case .stuck: self = .stuck
case .unknown: self = .unknown
}
}
}
/// Resolved visuals for one status: a semantic color, a DISTINCT SF Symbol
/// shape, and a Chinese label (used as the VoiceOver string too). The mapping
/// itself is pure no UIKit, deterministic, unit-testable.
struct StatusStyle: Equatable, Sendable {
/// Semantic color from `DS.Palette` (never the sole signal see `symbolName`).
let color: Color
/// SF Symbol name. Distinct across all seven statuses so shape alone
/// disambiguates (color-blind safe).
let symbolName: String
/// Chinese status word shown as an optional label and always as the
/// accessibility label.
let label: String
/// The frozen mapping. Each status (color, distinct symbol, label).
static func style(for status: DisplayStatus) -> StatusStyle {
switch status {
case .working:
// solid filled circle actively running
return StatusStyle(color: DS.Palette.statusWorking, symbolName: "circle.fill", label: "运行中")
case .waiting:
// clock waiting on something
return StatusStyle(color: DS.Palette.statusWaiting, symbolName: "clock.fill", label: "等待中")
case .idle:
// hollow circle quiet/empty (gray, distinct from working's fill)
return StatusStyle(color: DS.Palette.statusIdle, symbolName: "circle", label: "空闲")
case .stuck:
// triangle alarm
return StatusStyle(color: DS.Palette.statusStuck, symbolName: "exclamationmark.triangle.fill", label: "卡住")
case .unknown:
// question mark no signal yet
return StatusStyle(color: DS.Palette.statusUnknown, symbolName: "questionmark.circle", label: "未知")
case .pendingApproval:
// filled ! circle "needs me", the single most important prompt
return StatusStyle(color: DS.Palette.statusWaiting, symbolName: "exclamationmark.circle.fill", label: "等待审批")
case .exited:
// checkered flag session over (mirrors ReconnectBanner's exited icon)
return StatusStyle(color: DS.Palette.statusExited, symbolName: "flag.checkered", label: "已退出")
}
}
/// Convenience bridge from the wire enum.
static func style(for claude: ClaudeStatus) -> StatusStyle {
style(for: DisplayStatus(claude))
}
}

View File

@@ -0,0 +1,197 @@
import SwiftUI
import UIKit
/// # WebTerm Design System token vocabulary (FROZEN public surface)
///
/// The single source of truth for every visual constant in the App layer.
/// Screens/components must reference these tokens never inline colors,
/// spacings, radii, opacities, durations or haptics. Direction: ""
/// (refined native, Apple HIG), dark-mode-first, indigo/violet accent
/// continuing the web selection color.
///
/// Vocabulary (all under `DS.`):
/// - `Palette` adaptive `accent` (indigo) · semantic `status*` colors
/// (color is NEVER the only status signal pair with a symbol,
/// see `StatusStyle`) · `surface`/`card`/`hairline` surfaces ·
/// `textPrimary`/`textSecondary`/`textTertiary`.
/// - `Space` 2·4·8·12·16·20·24 scale (`xs2``xxl24`). No off-scale gaps.
/// - `Radius` `sm8`/`md12`/`lg16`/`pill`.
/// - `Stroke` `hairline` (1pt border width).
/// - `Opacity` `stale`/`exited`/`pressed` dimming multipliers.
/// - `Layout` `minHitTarget` (44pt HIG minimum).
/// - `Motion` `fast`/`base` eased animations + `gated(_:reduceMotion:)`
/// which collapses to `nil` under Reduce Motion.
/// - `Haptics` `selection`/`success`/`warning` (`@MainActor`).
///
/// Companion files: `Typography.swift` (`DS.Typography`), `StatusStyle.swift`
/// (`StatusStyle` / `DisplayStatus`), `Primitives.swift` (reusable views).
enum DS {
// MARK: - Palette
/// Colors. `accent` is asset-free adaptive (no `.xcassets`); the semantic
/// status colors use the direction's exact hex so light/dark stay on-brand.
enum Palette {
// Accent (indigo/violet web selection color #7C8CFF)
// Adaptive: dark #7C8CFF, light #4F5BD5. Used sparingly primary
// actions, selection, active state only.
/// The one accent token. Inject once at the root via `.tint(DS.Palette.accent)`.
static let accent = Color(uiColor: accentUIColor())
/// The accent as a dynamic `UIColor`. Exposed so tests can resolve the
/// two schemes deterministically (`resolvedColor(with:)`) without going
/// through the SwiftUIUIKit bridge.
static func accentUIColor() -> UIColor {
UIColor { trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0x7C / 255.0, green: 0x8C / 255.0, blue: 0xFF / 255.0, alpha: 1)
: UIColor(red: 0x4F / 255.0, green: 0x5B / 255.0, blue: 0xD5 / 255.0, alpha: 1)
}
}
// Semantic status colors (exact hex from the direction)
// These are the ONLY status colors. `StatusStyle` pairs each with a
// distinct SF Symbol so status is never conveyed by color alone.
/// working #34C759 (green).
static let statusWorking = rgb(52, 199, 89)
/// waiting / needs-me #FF9F0A (amber).
static let statusWaiting = rgb(255, 159, 10)
/// stuck #FF453A (red).
static let statusStuck = rgb(255, 69, 58)
/// idle quiet secondary gray (distinguished from `unknown` by shape).
static let statusIdle = Color.secondary
/// unknown / no signal yet gray.
static let statusUnknown = Color.gray
/// exited dimmed secondary (apply `Opacity.exited` on the container).
static let statusExited = Color.secondary
// Timeline event classes (T-iOS-24)
// Semantic colors for the activity-timeline event classes. `waiting`
// and `stuck` reuse the status tokens above (same meaning); `done`
// reuses `statusWorking` (a completed run). `tool`/`user` get their own
// tokens so nothing in the app hardcodes a raw SwiftUI color.
/// tool run indigo, tied to the app accent family (#5E9EFF-ish).
static let timelineTool = rgb(94, 158, 255)
/// user message violet (#AF7BFF), distinct from accent & tool.
static let timelineUser = rgb(175, 123, 255)
// Surfaces & text
/// Base screen background.
static let surface = Color(uiColor: .systemBackground)
/// Card / grouped-content background (a step up from `surface`).
static let card = Color(uiColor: .secondarySystemBackground)
/// Hairline separator/border color.
static let hairline = Color(uiColor: .separator)
/// Primary label color.
static let textPrimary = Color.primary
/// Secondary label color (meta, captions).
static let textSecondary = Color.secondary
/// Tertiary label color (de-emphasized detail).
static let textTertiary = Color(uiColor: .tertiaryLabel)
/// Build an opaque sRGB color from 0255 components.
private static func rgb(_ r: Double, _ g: Double, _ b: Double) -> Color {
Color(.sRGB, red: r / 255, green: g / 255, blue: b / 255, opacity: 1)
}
}
// MARK: - Space
/// Spacing scale 2·4·8·12·16·20·24. Every gap/padding picks one of these;
/// there are no in-between values (the audit's 3/5/6/10/14 all round here).
enum Space {
static let xs2: CGFloat = 2
static let xs4: CGFloat = 4
static let sm8: CGFloat = 8
static let md12: CGFloat = 12
static let lg16: CGFloat = 16
static let xl20: CGFloat = 20
static let xxl24: CGFloat = 24
}
// MARK: - Radius
/// Corner radii. Legacy 6/10 both fold into `sm8`/`md12`.
enum Radius {
static let sm8: CGFloat = 8
static let md12: CGFloat = 12
static let lg16: CGFloat = 16
/// Fully-rounded (capsule/pill).
static let pill: CGFloat = 999
}
// MARK: - Stroke
/// Border widths.
enum Stroke {
/// Hairline card/overlay border.
static let hairline: CGFloat = 1
}
// MARK: - Opacity
/// Dimming multipliers (used with `.opacity()`, sometimes `.grayscale()`).
enum Opacity {
/// Telemetry gone stale (past its TTL).
static let stale: Double = 0.45
/// A session that has exited.
static let exited: Double = 0.55
/// Pressed-state feedback on buttons.
static let pressed: Double = 0.72
}
// MARK: - Layout
/// Layout constants that are not spacings.
enum Layout {
/// HIG minimum touch target (44×44pt).
static let minHitTarget: CGFloat = 44
}
// MARK: - Motion
/// Animation tokens. Subtle, eased (~0.180.25s). ALWAYS route through
/// `gated(_:reduceMotion:)` so Reduce Motion collapses to no motion.
enum Motion {
static let fastDuration: Double = 0.18
static let baseDuration: Double = 0.25
/// Quick affordance (chips, presses).
static let fast = Animation.easeInOut(duration: fastDuration)
/// Standard transition (banners, sheets, list changes).
static let base = Animation.easeInOut(duration: baseDuration)
/// Returns `animation` normally, or `nil` (instant, no motion) when
/// Reduce Motion is enabled. Callers pass the environment flag.
static func gated(_ animation: Animation, reduceMotion: Bool) -> Animation? {
reduceMotion ? nil : animation
}
}
// MARK: - Haptics
/// Tactile feedback for key interactions. `@MainActor` the underlying
/// UIKit generators must be touched on the main thread. Additive to the
/// existing `GateViewModel` gate-arrival haptic (different call sites).
@MainActor
enum Haptics {
/// Light selection tap opening a session, tapping a key.
static func selection() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
/// Success notification a gate approve resolved.
static func success() {
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
/// Warning notification a destructive/reject decision.
static func warning() {
UINotificationFeedbackGenerator().notificationOccurred(.warning)
}
}
}

View File

@@ -0,0 +1,60 @@
import SwiftUI
/// # Typography the SF type ramp (FROZEN public surface)
///
/// All font choices come from `DS.Typography`. The ramp maps to Apple's
/// semantic text styles so everything scales with Dynamic Type (the a11y
/// audit's "keep it scalable" point). Numbers/dimensions/cost/`cols×rows`/
/// timestamps use `mono(_:)` SF Mono with tabular figures so columns line
/// up and digits don't jitter as values change (matches the existing
/// `TelemetryChips`/`Timeline` convention, now centralized).
extension DS {
enum Typography {
// Proportional ramp (Dynamic-Type scaling, semantic styles)
/// Screen hero title.
static let largeTitle = Font.largeTitle
/// Section / prominent title.
static let title = Font.title2
/// Emphasis / card heading.
static let headline = Font.headline
/// Default body text.
static let body = Font.body
/// Slightly smaller body (secondary actions).
static let callout = Font.callout
/// Meta / caption text.
static let caption = Font.caption
// Monospaced (numbers, dimensions, timestamps)
/// SF Mono + tabular figures at the given text style (default `.body`).
/// Use for anything numeric that must align or not jump: `cols×rows`,
/// device/client counts, `$cost`, context %, relative timestamps.
static func mono(_ style: Font.TextStyle = .body) -> Font {
.system(style, design: .monospaced).monospacedDigit()
}
/// The canonical meta-number font: caption-sized mono + tabular.
static let metaMono = mono(.caption)
}
}
// MARK: - MetaText style
/// Secondary + monospaced-tabular styling for a row's numeric meta line
/// ("N · 161×50"). Apply via `.dsMetaText()`.
struct MetaText: ViewModifier {
func body(content: Content) -> some View {
content
.font(DS.Typography.metaMono)
.foregroundStyle(DS.Palette.textSecondary)
}
}
extension View {
/// Style a meta/number line as secondary monospaced-tabular text.
func dsMetaText() -> some View {
modifier(MetaText())
}
}

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
}
}
}

View File

@@ -20,6 +20,14 @@ enum PrivacyShadePolicy {
/// Opaque cover rendered ABOVE the whole navigation tree (RootView ZStack)
/// deliberately no animation: the snapshot moment must never race a fade.
///
/// Security invariant (do NOT weaken): the FULL-SCREEN base is `DS.Palette.surface`
/// (`.systemBackground`, fully OPAQUE) + `.ignoresSafeArea()`, so no terminal
/// byte can leak into the on-disk switcher snapshot. The branded lockup is a
/// `.regularMaterial` card layered ON TOP of that opaque base (never over the
/// terminal), so the material's translucency is purely decorative it blurs
/// the opaque surface below it, not the PTY content. Accent lock + app name +
/// hint give the shade a refined-native identity instead of a flat cover.
///
/// Scope note (documented): sheets present in a separate presentation layer a
/// root overlay cannot cover but no terminal bytes are ever rendered in a
/// sheet (pairing / plan-gate only), so the root-level shade covers every
@@ -27,21 +35,36 @@ enum PrivacyShadePolicy {
struct PrivacyShadeView: View {
var body: some View {
ZStack {
Color(.systemBackground)
// OPAQUE full-screen occlusion the security base. Must stay opaque
// and edge-to-edge; the material card below only decorates it.
DS.Palette.surface
.ignoresSafeArea()
VStack(spacing: ShadeMetrics.spacing) {
Image(systemName: "terminal.fill")
.font(.largeTitle)
.foregroundStyle(.secondary)
Text("WebTerm")
.font(.headline)
.foregroundStyle(.secondary)
VStack(spacing: DS.Space.md12) {
Image(systemName: "lock.fill")
.font(DS.Typography.largeTitle)
.foregroundStyle(DS.Palette.accent)
Text(ShadeCopy.appName)
.font(DS.Typography.headline)
.foregroundStyle(DS.Palette.textPrimary)
Text(ShadeCopy.hint)
.font(DS.Typography.caption)
.foregroundStyle(DS.Palette.textSecondary)
}
.padding(.horizontal, DS.Space.xxl24)
.padding(.vertical, DS.Space.xl20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.lg16))
.overlay(
RoundedRectangle(cornerRadius: DS.Radius.lg16)
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
)
}
.accessibilityHidden(true)
}
private enum ShadeMetrics {
static let spacing: CGFloat = 12
}
}
/// Chinese, named constants
private enum ShadeCopy {
static let appName = "WebTerm"
static let hint = "内容已隐藏"
}

View File

@@ -5,19 +5,25 @@ import SwiftUI
/// + sheets/scenePhase/deepLink 线iPad T-iPad-2
///
/// - `RootView` struct `@main` WebTermApp
/// `RootView(coordinator:)` `AdaptiveRootView`
/// `RootView(coordinator:)` `AdaptiveRootView`
/// accent tint`DS.Palette.accent`DS
/// App //`.borderedProminent`
/// - `AdaptiveRootView` `horizontalSizeClass` `StackRootView`compact
/// `SplitRootView`regulariPad
/// / scenePhase / deepLink / sheets **线**
/// ZStack
///
/// `StackRootView` `RootView` body **** compact
/// iPhone
/// iPhone
/// `DS.*` token //
struct RootView: View {
@Bindable var coordinator: AppCoordinator
var body: some View {
AdaptiveRootView(coordinator: coordinator)
// DS tint Tokens.swift
// gate amber orange `.tint`
.tint(DS.Palette.accent)
}
}
@@ -26,6 +32,7 @@ struct RootView: View {
/// /scenePhase/deepLink/sheets `AdaptiveRootView`
struct StackRootView: View {
@Bindable var coordinator: AppCoordinator
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
NavigationStack {
@@ -66,38 +73,23 @@ struct StackRootView: View {
onAddHost: { coordinator.presentAddHost() }
)
.safeAreaInset(edge: .bottom) { continueLastBanner }
// / DS reduceMotion
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: coordinator.continueLastSessionId
)
// T-iOS-26 · Projects RootView SessionListScreen
// T-iOS-23 W7 leading
// topBarTrailing hostMenu
.toolbar { projectsToolbarItem }
}
@ToolbarContentBuilder private var projectsToolbarItem: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
Button {
coordinator.presentProjects()
} label: {
Label(RootCopy.projects, systemImage: "folder")
}
.disabled(coordinator.sessionList.activeHost == nil)
.accessibilityIdentifier("sessions.projectsButton")
}
.toolbar { ProjectsToolbarItem(coordinator: coordinator) }
}
// MARK: - "" highlight (cold start step 5)
@ViewBuilder private var continueLastBanner: some View {
if coordinator.continueLastSessionId != nil {
Button {
coordinator.openContinueLast()
} label: {
Label(RootCopy.continueLast, systemImage: "arrow.uturn.forward.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding(.horizontal, RootMetrics.bannerHorizontalPadding)
.padding(.vertical, RootMetrics.bannerVerticalPadding)
.background(.thinMaterial)
ContinueLastBanner { coordinator.openContinueLast() }
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
@@ -131,12 +123,54 @@ struct StackRootView: View {
}
}
enum RootMetrics {
static let bannerHorizontalPadding: CGFloat = 16
static let bannerVerticalPadding: CGFloat = 8
// MARK: - Continue-last banner (shared stack + split, DRY)
/// 5 CTA stack split sidebar
/// DRY`.regularMaterial` + +
/// accent `DSButtonStyle(.primary)`
/// `action`= `coordinator.openContinueLast()`
struct ContinueLastBanner: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Label(RootCopy.continueLast, systemImage: "arrow.uturn.forward.circle.fill")
}
.buttonStyle(DSButtonStyle(kind: .primary))
.accessibilityIdentifier("root.continueLastButton")
.padding(.horizontal, DS.Space.lg16)
.padding(.top, DS.Space.md12)
.padding(.bottom, DS.Space.sm8)
.background(.regularMaterial)
.overlay(alignment: .top) {
Rectangle()
.fill(DS.Palette.hairline)
.frame(height: DS.Stroke.hairline)
}
}
}
/// internal `SplitRootView` DRY
// MARK: - Projects toolbar item (shared stack + split, DRY)
/// leading stack split disabled
/// `presentProjects` a11y id tint label accent
struct ProjectsToolbarItem: ToolbarContent {
@Bindable var coordinator: AppCoordinator
var body: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
Button {
coordinator.presentProjects()
} label: {
Label(RootCopy.projects, systemImage: "folder")
}
.disabled(coordinator.sessionList.activeHost == nil)
.accessibilityIdentifier("sessions.projectsButton")
}
}
}
/// internal `SplitRootView` /DRY
enum RootCopy {
static let continueLast = "继续上次会话"
static let projects = "项目"

View File

@@ -10,12 +10,17 @@ import SwiftUI
/// split detail PLAN_IOS_IPAD §1
/// - ** `AppCoordinator` **sidebar
/// `selectSidebarItem` / `open` stack
/// `coordinator.sidebarSelection` highlight sidebar List
/// `selection:``SessionListScreen` List Owns
/// - **detail `.id(controller.id)`** controller
/// SwiftTerm ring bufferidentity T-iOS-29
/// - **** `AdaptiveRootView` ZStack
/// detail `scenePhase != .active`
/// - ****// `DS.*` token Primitive
/// stack
struct SplitRootView: View {
@Bindable var coordinator: AppCoordinator
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
NavigationSplitView {
@@ -37,23 +42,19 @@ struct SplitRootView: View {
onAddHost: { coordinator.presentAddHost() }
)
.safeAreaInset(edge: .bottom) { continueLastBanner }
.toolbar { projectsToolbarItem }
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: coordinator.continueLastSessionId
)
.toolbar { ProjectsToolbarItem(coordinator: coordinator) }
}
/// `StackRootView.continueLastBanner` 5
/// sidebar iPad T-iPad-5 finding
/// stack 5
/// `ContinueLastBanner`iPad T-iPad-5 finding
@ViewBuilder private var continueLastBanner: some View {
if coordinator.continueLastSessionId != nil {
Button {
coordinator.openContinueLast()
} label: {
Label(RootCopy.continueLast, systemImage: "arrow.uturn.forward.circle.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding(.horizontal, RootMetrics.bannerHorizontalPadding)
.padding(.vertical, RootMetrics.bannerVerticalPadding)
.background(.thinMaterial)
ContinueLastBanner { coordinator.openContinueLast() }
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
@@ -62,21 +63,6 @@ struct SplitRootView: View {
request.sessionId.map(SidebarItem.session) ?? .newSession
}
/// `StackRootView.projectsToolbarItem` leading disabled
/// `presentProjects` iPad Projects sheet
/// T-iPad-4
@ToolbarContentBuilder private var projectsToolbarItem: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
Button {
coordinator.presentProjects()
} label: {
Label(RootCopy.projects, systemImage: "folder")
}
.disabled(coordinator.sessionList.activeHost == nil)
.accessibilityIdentifier("sessions.projectsButton")
}
}
// MARK: - Detail
@ViewBuilder private var detail: some View {
@@ -91,19 +77,27 @@ struct SplitRootView: View {
}
} else {
placeholder
.transition(.opacity)
}
}
/// detail accent + DS ContentUnavailableView
/// + + accent App
private var placeholder: some View {
ContentUnavailableView {
Label(SplitCopy.placeholderTitle, systemImage: "sidebar.left")
Label {
Text(SplitCopy.placeholderTitle)
} icon: {
Image(systemName: "terminal")
.foregroundStyle(DS.Palette.accent)
}
} description: {
Text(SplitCopy.placeholderHint)
}
}
}
/// split `RootCopy.projects`DRY
/// split / `RootCopy`DRY
private enum SplitCopy {
static let placeholderTitle = "选择或新建会话"
static let placeholderHint = "从左侧选择一个运行中的会话,或新建一个开始工作。"

View File

@@ -35,18 +35,20 @@ struct TerminalContainerView: View {
/// T-iOS-25 (additive) · Quick-reply palette store per-container over
/// `.standard` defaults (non-secret UI prefs, plan §5.3 split).
@State private var quickReplyStore = QuickReplyStore()
/// overlay DS.Motion.gated
@Environment(\.accessibilityReduceMotion) private var reduceMotion
private enum Metrics {
static let overlaySpacing: CGFloat = 8
static let overlayHorizontalPadding: CGFloat = 12
/// Clears TerminalScreen's own top-aligned ReconnectBanner zone.
static let overlayTopPadding: CGFloat = 52
/// Breathing room between the chip row and the keyboard/key-bar edge.
static let quickReplyBottomPadding: CGFloat = 8
/// Clears TerminalScreen's own top-aligned ReconnectBanner zone: a
/// hit-target-tall pill plus a gap. (They rarely co-exist the banner
/// hides on `.connected` while gates/digests only arrive connected.)
static let overlayTopPadding: CGFloat = DS.Layout.minHitTarget + DS.Space.sm8
}
private enum Copy {
static let planGatePending = "⚠ 计划待批准"
/// The re-entry chip's own amber StatusBadge carries the "needs me"
/// warning shape, so the copy itself stays plain (no emoji).
static let planGatePending = "计划待批准"
}
var body: some View {
@@ -75,16 +77,19 @@ struct TerminalContainerView: View {
gateViewModel: controller.gateViewModel,
store: quickReplyStore
)
.padding(.horizontal, Metrics.overlayHorizontalPadding)
.padding(.bottom, Metrics.quickReplyBottomPadding)
.animation(.default, value: controller.gateViewModel.currentGate)
.padding(.horizontal, DS.Space.md12)
.padding(.bottom, DS.Space.sm8)
.animation(
DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: controller.gateViewModel.currentGate
)
}
// MARK: - Digest + tool gate (top stack)
@ViewBuilder private var topOverlays: some View {
let gateViewModel = controller.gateViewModel
VStack(spacing: Metrics.overlaySpacing) {
VStack(spacing: DS.Space.sm8) {
if let digest = gateViewModel.digest {
AwayDigestView(
digest: digest,
@@ -99,16 +104,38 @@ struct TerminalContainerView: View {
}
}
if hasDismissedPendingPlanGate {
Button(Copy.planGatePending) { dismissedPlanGateEpoch = nil }
.buttonStyle(.borderedProminent)
.tint(.orange)
.controlSize(.small)
planGatePendingChip
}
}
.padding(.horizontal, Metrics.overlayHorizontalPadding)
.padding(.horizontal, DS.Space.md12)
.padding(.top, Metrics.overlayTopPadding)
.animation(.default, value: gateViewModel.toolGate)
.animation(.default, value: gateViewModel.digest)
.animation(DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: gateViewModel.toolGate)
.animation(DS.Motion.gated(DS.Motion.base, reduceMotion: reduceMotion),
value: gateViewModel.digest)
}
/// The "plan still pending" re-entry chip shown after the user swiped the
/// plan sheet away an amber pending-approval pill that re-presents the
/// sheet on tap.
private var planGatePendingChip: some View {
Button {
dismissedPlanGateEpoch = nil
} label: {
HStack(spacing: DS.Space.xs4) {
StatusBadge(status: .pendingApproval)
Text(Copy.planGatePending)
.font(DS.Typography.callout.weight(.medium))
.foregroundStyle(DS.Palette.textPrimary)
}
.padding(.horizontal, DS.Space.md12)
.frame(minHeight: DS.Layout.minHitTarget)
.background(.regularMaterial, in: Capsule())
.overlay(
Capsule().strokeBorder(DS.Palette.statusWaiting, lineWidth: DS.Stroke.hairline)
)
}
.buttonStyle(.plain)
}
// MARK: - Timeline drill-down (T-iOS-24, additive)