From 660a40491a659e681280a3c3c75485125ff20a26 Mon Sep 17 00:00:00 2001 From: Yaojia Wang Date: Sun, 5 Jul 2026 22:00:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(ios):=20comprehensive=20iPhone+iPad=20UX?= =?UTF-8?q?=20polish=20=E2=80=94=20refined-native=20design=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/. --- .../WebTerm/Components/AwayDigestView.swift | 40 ++- ios/App/WebTerm/Components/GateBanner.swift | 65 ++-- ios/App/WebTerm/Components/KeyBar.swift | 28 +- .../WebTerm/Components/PlanGateSheet.swift | 91 +++-- ios/App/WebTerm/Components/QuickReply.swift | 46 +-- .../WebTerm/Components/ReconnectBanner.swift | 61 ++-- .../WebTerm/Components/SessionThumbnail.swift | 53 +-- .../WebTerm/Components/TelemetryChips.swift | 48 +-- ios/App/WebTerm/DesignSystem/Primitives.swift | 222 ++++++++++++ .../WebTerm/DesignSystem/StatusStyle.swift | 86 +++++ ios/App/WebTerm/DesignSystem/Tokens.swift | 197 +++++++++++ ios/App/WebTerm/DesignSystem/Typography.swift | 60 ++++ ios/App/WebTerm/Screens/DiffScreen.swift | 86 ++--- ios/App/WebTerm/Screens/PairingScreen.swift | 327 ++++++++++-------- .../WebTerm/Screens/ProjectDetailScreen.swift | 129 ++++--- ios/App/WebTerm/Screens/ProjectsScreen.swift | 106 +++--- .../WebTerm/Screens/SessionListScreen.swift | 245 ++++++++----- ios/App/WebTerm/Screens/TerminalScreen.swift | 37 +- ios/App/WebTerm/Screens/TimelineSheet.swift | 35 +- ios/App/WebTerm/Wiring/PrivacyShade.swift | 47 ++- ios/App/WebTerm/Wiring/RootView.swift | 92 +++-- ios/App/WebTerm/Wiring/SplitRootView.swift | 54 ++- .../Wiring/TerminalContainerView.swift | 63 +++- ios/App/WebTermTests/DesignSystemTests.swift | 158 +++++++++ ios/App/WebTermTests/TimelineSheetTests.swift | 16 +- 25 files changed, 1742 insertions(+), 650 deletions(-) create mode 100644 ios/App/WebTerm/DesignSystem/Primitives.swift create mode 100644 ios/App/WebTerm/DesignSystem/StatusStyle.swift create mode 100644 ios/App/WebTerm/DesignSystem/Tokens.swift create mode 100644 ios/App/WebTerm/DesignSystem/Typography.swift create mode 100644 ios/App/WebTermTests/DesignSystemTests.swift diff --git a/ios/App/WebTerm/Components/AwayDigestView.swift b/ios/App/WebTerm/Components/AwayDigestView.swift index c00222c..5895154 100644 --- a/ios/App/WebTerm/Components/AwayDigestView.swift +++ b/ios/App/WebTerm/Components/AwayDigestView.swift @@ -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, oldest→newest. 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) } } diff --git a/ios/App/WebTerm/Components/GateBanner.swift b/ios/App/WebTerm/Components/GateBanner.swift index a22760b..4dbb810 100644 --- a/ios/App/WebTerm/Components/GateBanner.swift +++ b/ios/App/WebTerm/Components/GateBanner.swift @@ -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 } } } diff --git a/ios/App/WebTerm/Components/KeyBar.swift b/ios/App/WebTerm/Components/KeyBar.swift index 9236553..c803476 100644 --- a/ios/App/WebTerm/Components/KeyBar.swift +++ b/ios/App/WebTerm/Components/KeyBar.swift @@ -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 diff --git a/ios/App/WebTerm/Components/PlanGateSheet.swift b/ios/App/WebTerm/Components/PlanGateSheet.swift index d36a243..5280b40 100644 --- a/ios/App/WebTerm/Components/PlanGateSheet.swift +++ b/ios/App/WebTerm/Components/PlanGateSheet.swift @@ -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 } } } diff --git a/ios/App/WebTerm/Components/QuickReply.swift b/ios/App/WebTerm/Components/QuickReply.swift index e7362ff..caf235b 100644 --- a/ios/App/WebTerm/Components/QuickReply.swift +++ b/ios/App/WebTerm/Components/QuickReply.swift @@ -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) } } diff --git a/ios/App/WebTerm/Components/ReconnectBanner.swift b/ios/App/WebTerm/Components/ReconnectBanner.swift index fb0842e..081b922 100644 --- a/ios/App/WebTerm/Components/ReconnectBanner.swift +++ b/ios/App/WebTerm/Components/ReconnectBanner.swift @@ -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 } } diff --git a/ios/App/WebTerm/Components/SessionThumbnail.swift b/ios/App/WebTerm/Components/SessionThumbnail.swift index 335b3e9..4619f5d 100644 --- a/ios/App/WebTerm/Components/SessionThumbnail.swift +++ b/ios/App/WebTerm/Components/SessionThumbnail.swift @@ -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) } } } diff --git a/ios/App/WebTerm/Components/TelemetryChips.swift b/ios/App/WebTerm/Components/TelemetryChips.swift index 7c9ee24..84c7502 100644 --- a/ios/App/WebTerm/Components/TelemetryChips.swift +++ b/ios/App/WebTerm/Components/TelemetryChips.swift @@ -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 { diff --git a/ios/App/WebTerm/DesignSystem/Primitives.swift b/ios/App/WebTerm/DesignSystem/Primitives.swift new file mode 100644 index 0000000..0b38cbe --- /dev/null +++ b/ios/App/WebTerm/DesignSystem/Primitives.swift @@ -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: 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) +} diff --git a/ios/App/WebTerm/DesignSystem/StatusStyle.swift b/ios/App/WebTerm/DesignSystem/StatusStyle.swift new file mode 100644 index 0000000..eb74f74 --- /dev/null +++ b/ios/App/WebTerm/DesignSystem/StatusStyle.swift @@ -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)) + } +} diff --git a/ios/App/WebTerm/DesignSystem/Tokens.swift b/ios/App/WebTerm/DesignSystem/Tokens.swift new file mode 100644 index 0000000..55037c9 --- /dev/null +++ b/ios/App/WebTerm/DesignSystem/Tokens.swift @@ -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 SwiftUI→UIKit 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 0–255 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.18–0.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) + } + } +} diff --git a/ios/App/WebTerm/DesignSystem/Typography.swift b/ios/App/WebTerm/DesignSystem/Typography.swift new file mode 100644 index 0000000..f49f1c1 --- /dev/null +++ b/ios/App/WebTerm/DesignSystem/Typography.swift @@ -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()) + } +} diff --git a/ios/App/WebTerm/Screens/DiffScreen.swift b/ios/App/WebTerm/Screens/DiffScreen.swift index f05bcbd..3d7ab50 100644 --- a/ios/App/WebTerm/Screens/DiffScreen.swift +++ b/ios/App/WebTerm/Screens/DiffScreen.swift @@ -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.Palette(added=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 } } } diff --git a/ios/App/WebTerm/Screens/PairingScreen.swift b/ios/App/WebTerm/Screens/PairingScreen.swift index fdfb34c..2d00fee 100644 --- a/ios/App/WebTerm/Screens/PairingScreen.swift +++ b/ios/App/WebTerm/Screens/PairingScreen.swift @@ -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 serve(wss)。" - static let publicWarning = - "这是公网地址!任何能连上该端口的人都会得到你电脑的 shell。web-terminal 绝不应暴露到公网。" + static let plaintextNotice = "ws:// 明文连接:键击与终端输出可被同一网络内的设备嗅探。仅限可信 LAN,推荐 tailscale serve(wss)。" + 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)。可改用手输地址。" } diff --git a/ios/App/WebTerm/Screens/ProjectDetailScreen.swift b/ios/App/WebTerm/Screens/ProjectDetailScreen.swift index 4efc847..c4b719d 100644 --- a/ios/App/WebTerm/Screens/ProjectDetailScreen.swift +++ b/ios/App/WebTerm/Screens/ProjectDetailScreen.swift @@ -15,8 +15,7 @@ struct ProjectDetailScreen: View { @State private var isDiffPresented = false private enum Metrics { - static let headerSpacing: CGFloat = 4 - static let chipSpacing: CGFloat = 6 + /// CLAUDE.md 预览行数上限(内容裁剪,非视觉 token)。 static let claudeMdLineLimit = 40 } @@ -68,6 +67,7 @@ struct ProjectDetailScreen: View { Task { await viewModel.load() } } .buttonStyle(.borderedProminent) + .tint(DS.Palette.accent) } } @@ -102,29 +102,29 @@ struct ProjectDetailScreen: View { private func headerSection(_ detail: ProjectDetail) -> some View { Section { - VStack(alignment: .leading, spacing: Metrics.headerSpacing) { + VStack(alignment: .leading, spacing: DS.Space.xs4) { Text(verbatim: detail.name) - .font(.headline) + .font(DS.Typography.headline) + .foregroundStyle(DS.Palette.textPrimary) .lineLimit(1) + // 路径是服务器字节 → verbatim + 等宽单行中段截断。 Text(verbatim: detail.path) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) + .font(DS.Typography.mono(.caption)) + .foregroundStyle(DS.Palette.textSecondary) .lineLimit(1) .truncationMode(.middle) - HStack(spacing: Metrics.chipSpacing) { + HStack(spacing: DS.Space.sm8) { if let branch = detail.branch { Label { Text(verbatim: branch).lineLimit(1) } icon: { Image(systemName: "arrow.triangle.branch") } - .font(.caption) - .foregroundStyle(.secondary) + .font(DS.Typography.caption) + .foregroundStyle(DS.Palette.textSecondary) } if detail.dirty == true { - Text(ProjectsCopy.dirtyBadge) - .font(.caption2) - .foregroundStyle(.orange) + DirtyBadge() } } } @@ -134,13 +134,16 @@ struct ProjectDetailScreen: View { private func actionsSection(_ detail: ProjectDetail) -> some View { Section { Button { + DS.Haptics.selection() onOpenClaude(detail.path) } label: { Label(ProjectDetailCopy.openClaude, systemImage: "terminal.fill") - .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) - .listRowInsets(EdgeInsets()) + .buttonStyle(DSButtonStyle(kind: .primary)) + .listRowInsets(EdgeInsets( + top: DS.Space.sm8, leading: DS.Space.lg16, + bottom: detail.isGit ? DS.Space.xs4 : DS.Space.sm8, trailing: DS.Space.lg16 + )) .listRowBackground(Color.clear) if detail.isGit { Button { @@ -148,6 +151,12 @@ struct ProjectDetailScreen: View { } label: { Label(ProjectDetailCopy.viewDiff, systemImage: "plus.forwardslash.minus") } + .buttonStyle(DSButtonStyle(kind: .secondary)) + .listRowInsets(EdgeInsets( + top: DS.Space.xs4, leading: DS.Space.lg16, + bottom: DS.Space.sm8, trailing: DS.Space.lg16 + )) + .listRowBackground(Color.clear) } } } @@ -156,8 +165,8 @@ struct ProjectDetailScreen: View { Section(ProjectDetailCopy.sessionsHeader) { if sessions.isEmpty { Text(ProjectDetailCopy.noSessions) - .font(.footnote) - .foregroundStyle(.secondary) + .font(DS.Typography.caption) + .foregroundStyle(DS.Palette.textSecondary) } else { ForEach(sessions, id: \.id) { session in sessionRow(session) @@ -167,37 +176,29 @@ struct ProjectDetailScreen: View { } private func sessionRow(_ session: ProjectSessionRef) -> some View { - HStack(spacing: Metrics.chipSpacing) { - Text(verbatim: Self.statusGlyph(session.status)) + HStack(spacing: DS.Space.sm8) { + // 状态 = 色 + 形 + VoiceOver 标签(DS 单一状态真源);退出优先。 + StatusBadge(status: session.exited ? .exited : DisplayStatus(session.status)) // title 是派生的 cwd 尾段(服务器字节)→ verbatim;缺失退回短 id。 Text(verbatim: session.title ?? String( session.id.uuidString.lowercased().prefix(8) )) + .font(DS.Typography.body) + .foregroundStyle(DS.Palette.textPrimary) .lineLimit(1) Spacer(minLength: 0) if session.exited { Text(ProjectDetailCopy.sessionExited) - .font(.caption) - .foregroundStyle(.secondary) + .font(DS.Typography.caption) + .foregroundStyle(DS.Palette.textSecondary) } else { + // 客户端数含数字 → 等宽 tabular 元信息。 Text(ProjectDetailCopy.clientCount(session.clientCount)) - .font(.caption) - .foregroundStyle(.secondary) + .dsMetaText() } } } - /// 镜像 web claudeIcon(public/tabs.ts:74-80)。 - static func statusGlyph(_ status: ClaudeStatus) -> String { - switch status { - case .working: return "⚙" - case .waiting: return "⏳" - case .idle: return "✓" - case .stuck: return "⚠" - case .unknown: return "" - } - } - @ViewBuilder private func worktreesSection(_ worktrees: [WorktreeInfo]) -> some View { if !worktrees.isEmpty { Section(ProjectDetailCopy.worktreesHeader) { @@ -209,53 +210,79 @@ struct ProjectDetailScreen: View { } private func worktreeRow(_ worktree: WorktreeInfo) -> some View { - VStack(alignment: .leading, spacing: Metrics.headerSpacing) { - HStack(spacing: Metrics.chipSpacing) { + VStack(alignment: .leading, spacing: DS.Space.xs4) { + HStack(spacing: DS.Space.sm8) { Text(verbatim: worktree.branch ?? ProjectDetailCopy.detachedHead) - .font(.callout) + .font(DS.Typography.callout) + .foregroundStyle(DS.Palette.textPrimary) .lineLimit(1) if worktree.isMain { - badge(ProjectDetailCopy.worktreeMain) + TagBadge(text: ProjectDetailCopy.worktreeMain) } if worktree.isCurrent { - badge(ProjectDetailCopy.worktreeCurrent) + TagBadge(text: ProjectDetailCopy.worktreeCurrent) } if worktree.locked == true { - badge(ProjectDetailCopy.worktreeLocked) + TagBadge(text: ProjectDetailCopy.worktreeLocked) } } Text(verbatim: worktree.path) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) + .font(DS.Typography.mono(.caption)) + .foregroundStyle(DS.Palette.textSecondary) .lineLimit(1) .truncationMode(.middle) } } - private func badge(_ text: String) -> some View { - Text(text) - .font(.caption2) - .foregroundStyle(.blue) - } - @ViewBuilder private func claudeMdSection(_ detail: ProjectDetail) -> some View { if detail.hasClaudeMd { Section(ProjectDetailCopy.claudeMdHeader) { if let content = detail.claudeMd { // 服务器已截断供展示;仍是不可信字节 → verbatim + 行数上限。 Text(verbatim: content) - .font(.caption.monospaced()) + .font(DS.Typography.mono(.caption)) + .foregroundStyle(DS.Palette.textPrimary) .lineLimit(Metrics.claudeMdLineLimit) } else { Text(ProjectDetailCopy.claudeMdPresent) - .font(.footnote) - .foregroundStyle(.secondary) + .font(DS.Typography.caption) + .foregroundStyle(DS.Palette.textSecondary) } } } } } +// MARK: - 小徽标(DS token 化,跨 Projects/详情复用) + +/// 「未提交」脏标:琥珀语义色文字 + 中性胶囊底(同 TelemetryChip 的 `.quaternary`)。 +struct DirtyBadge: View { + var body: some View { + Text(ProjectsCopy.dirtyBadge) + .font(DS.Typography.caption) + .foregroundStyle(DS.Palette.statusWaiting) + .padding(.horizontal, DS.Space.sm8) + .padding(.vertical, DS.Space.xs2) + .background(.quaternary, in: Capsule()) + } +} + +/// worktree 属性标签(主/当前/已锁定):accent 描边胶囊。 +struct TagBadge: View { + let text: String + + var body: some View { + Text(text) + .font(DS.Typography.caption) + .foregroundStyle(DS.Palette.accent) + .padding(.horizontal, DS.Space.sm8) + .padding(.vertical, DS.Space.xs2) + .overlay( + Capsule().strokeBorder(DS.Palette.accent, lineWidth: DS.Stroke.hairline) + ) + } +} + // MARK: - 用户可见文案(中文具名常量,plan §4) enum ProjectDetailCopy { diff --git a/ios/App/WebTerm/Screens/ProjectsScreen.swift b/ios/App/WebTerm/Screens/ProjectsScreen.swift index a1b9b98..f8be724 100644 --- a/ios/App/WebTerm/Screens/ProjectsScreen.swift +++ b/ios/App/WebTerm/Screens/ProjectsScreen.swift @@ -18,15 +18,9 @@ struct ProjectsScreen: View { /// idiom 而非 size class(iPad 表单 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(.pad)分支,iPhone 不受影响。 - 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: - Grid(regular 宽度:多列网格) /// 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 Card(secondary 底 + 发丝描边 + 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() } } } diff --git a/ios/App/WebTerm/Screens/SessionListScreen.swift b/ios/App/WebTerm/Screens/SessionListScreen.swift index 5af70ab..39a3a3f 100644 --- a/ios/App/WebTerm/Screens/SessionListScreen.swift +++ b/ios/App/WebTerm/Screens/SessionListScreen.swift @@ -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) 台设备在看" diff --git a/ios/App/WebTerm/Screens/TerminalScreen.swift b/ios/App/WebTerm/Screens/TerminalScreen.swift index 43464b0..3701bd4 100644 --- a/ios/App/WebTerm/Screens/TerminalScreen.swift +++ b/ios/App/WebTerm/Screens/TerminalScreen.swift @@ -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) } diff --git a/ios/App/WebTerm/Screens/TimelineSheet.swift b/ios/App/WebTerm/Screens/TimelineSheet.swift index 46f3b89..8fbc84b 100644 --- a/ios/App/WebTerm/Screens/TimelineSheet.swift +++ b/ios/App/WebTerm/Screens/TimelineSheet.swift @@ -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 } } } diff --git a/ios/App/WebTerm/Wiring/PrivacyShade.swift b/ios/App/WebTerm/Wiring/PrivacyShade.swift index 30f1982..2222790 100644 --- a/ios/App/WebTerm/Wiring/PrivacyShade.swift +++ b/ios/App/WebTerm/Wiring/PrivacyShade.swift @@ -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 = "内容已隐藏" } diff --git a/ios/App/WebTerm/Wiring/RootView.swift b/ios/App/WebTerm/Wiring/RootView.swift index 5181dc6..86e6486 100644 --- a/ios/App/WebTerm/Wiring/RootView.swift +++ b/ios/App/WebTerm/Wiring/RootView.swift @@ -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`(regular,iPad 分栏),并把 /// 隐私遮罩 / 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 = "项目" diff --git a/ios/App/WebTerm/Wiring/SplitRootView.swift b/ios/App/WebTerm/Wiring/SplitRootView.swift index 19d7fc0..6a9a678 100644 --- a/ios/App/WebTerm/Wiring/SplitRootView.swift +++ b/ios/App/WebTerm/Wiring/SplitRootView.swift @@ -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 buffer),identity 稳定复用 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 = "从左侧选择一个运行中的会话,或新建一个开始工作。" diff --git a/ios/App/WebTerm/Wiring/TerminalContainerView.swift b/ios/App/WebTerm/Wiring/TerminalContainerView.swift index 8b3b086..5d1df9d 100644 --- a/ios/App/WebTerm/Wiring/TerminalContainerView.swift +++ b/ios/App/WebTerm/Wiring/TerminalContainerView.swift @@ -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) diff --git a/ios/App/WebTermTests/DesignSystemTests.swift b/ios/App/WebTermTests/DesignSystemTests.swift new file mode 100644 index 0000000..ed35716 --- /dev/null +++ b/ios/App/WebTermTests/DesignSystemTests.swift @@ -0,0 +1,158 @@ +import SwiftUI +import Testing +import UIKit +import WireProtocol +@testable import WebTerm + +/// UX-A · Proves the FROZEN design system: the status mapping is complete and +/// "color + shape" (never color alone), the token scales are well-formed, and +/// the adaptive accent resolves distinctly in light vs dark. Pure logic — runs +/// on both iPhone and iPad sims with no rendering. +@Suite("DesignSystem") +struct DesignSystemTests { + + // MARK: - StatusStyle: complete, distinct, color + shape + + @Test("every status maps to a non-empty symbol + Chinese label") + func everyStatusHasSymbolAndLabel() { + for status in DisplayStatus.allCases { + let style = StatusStyle.style(for: status) + #expect(!style.symbolName.isEmpty, "\(status) has no symbol") + #expect(!style.label.isEmpty, "\(status) has no label") + } + } + + @Test("status is never color-alone — all seven symbols are distinct") + func symbolsAreDistinctAcrossStatuses() { + let symbols = DisplayStatus.allCases.map { StatusStyle.style(for: $0).symbolName } + #expect(Set(symbols).count == DisplayStatus.allCases.count) + } + + @Test("Chinese labels are all distinct") + func labelsAreDistinct() { + let labels = DisplayStatus.allCases.map { StatusStyle.style(for: $0).label } + #expect(Set(labels).count == DisplayStatus.allCases.count) + } + + @Test("the critical trio working/waiting/stuck use distinct colors") + func criticalColorsAreDistinct() { + let working = rgba(StatusStyle.style(for: DisplayStatus.working).color) + let waiting = rgba(StatusStyle.style(for: DisplayStatus.waiting).color) + let stuck = rgba(StatusStyle.style(for: DisplayStatus.stuck).color) + #expect(!approxEqual(working, waiting)) + #expect(!approxEqual(working, stuck)) + #expect(!approxEqual(waiting, stuck)) + } + + @Test("semantic status colors match the direction's hex") + func statusColorsMatchDirection() { + assertColor(StatusStyle.style(for: DisplayStatus.working).color, r: 52, g: 199, b: 89) // #34C759 + assertColor(StatusStyle.style(for: DisplayStatus.waiting).color, r: 255, g: 159, b: 10) // #FF9F0A + assertColor(StatusStyle.style(for: DisplayStatus.stuck).color, r: 255, g: 69, b: 58) // #FF453A + } + + @Test("the wire ClaudeStatus bridge covers all five cases") + func wireBridgeCoversEveryCase() { + let pairs: [(ClaudeStatus, DisplayStatus)] = [ + (.working, .working), (.waiting, .waiting), (.idle, .idle), + (.stuck, .stuck), (.unknown, .unknown), + ] + for (wire, expected) in pairs { + #expect(DisplayStatus(wire) == expected) + #expect(StatusStyle.style(for: wire) == StatusStyle.style(for: expected)) + } + } + + // MARK: - Token scales are well-formed + + @Test("spacing scale is exactly 2·4·8·12·16·20·24 and strictly increasing") + func spacingScaleIsMonotonic() { + let scale: [CGFloat] = [ + DS.Space.xs2, DS.Space.xs4, DS.Space.sm8, + DS.Space.md12, DS.Space.lg16, DS.Space.xl20, DS.Space.xxl24, + ] + #expect(scale == [2, 4, 8, 12, 16, 20, 24]) + #expect(isStrictlyIncreasing(scale)) + } + + @Test("radius scale is strictly increasing sm8 < md12 < lg16 < pill") + func radiusScaleIsMonotonic() { + let scale: [CGFloat] = [DS.Radius.sm8, DS.Radius.md12, DS.Radius.lg16, DS.Radius.pill] + #expect(scale == [8, 12, 16, 999]) + #expect(isStrictlyIncreasing(scale)) + } + + @Test("opacity dimming tokens are within (0, 1)") + func opacityTokensAreFractions() { + for value in [DS.Opacity.stale, DS.Opacity.exited, DS.Opacity.pressed] { + #expect(value > 0 && value < 1) + } + } + + @Test("minimum hit target meets the 44pt HIG floor") + func hitTargetMeetsHIG() { + #expect(DS.Layout.minHitTarget >= 44) + } + + // MARK: - Motion honors Reduce Motion + + @Test("motion collapses to nil under Reduce Motion, animates otherwise") + func motionGating() { + #expect(DS.Motion.gated(DS.Motion.base, reduceMotion: true) == nil) + #expect(DS.Motion.gated(DS.Motion.base, reduceMotion: false) != nil) + #expect(DS.Motion.fastDuration < DS.Motion.baseDuration) + } + + // MARK: - Adaptive accent resolves for both schemes + + @Test("accent is defined and distinct in light vs dark") + func accentIsAdaptive() { + let accent = DS.Palette.accentUIColor() + let dark = accent.resolvedColor(with: UITraitCollection(userInterfaceStyle: .dark)) + let light = accent.resolvedColor(with: UITraitCollection(userInterfaceStyle: .light)) + + let darkRGBA = rgba(dark) + let lightRGBA = rgba(light) + // Both fully opaque, and visibly different between schemes. + #expect(darkRGBA.a == 1) + #expect(lightRGBA.a == 1) + #expect(!approxEqual(darkRGBA, lightRGBA)) + // On-brand indigo: dark ≈ #7C8CFF, light ≈ #4F5BD5. + assertRGBA(darkRGBA, r: 0x7C, g: 0x8C, b: 0xFF) + assertRGBA(lightRGBA, r: 0x4F, g: 0x5B, b: 0xD5) + } + + // MARK: - Helpers + + private typealias RGBA = (r: CGFloat, g: CGFloat, b: CGFloat, a: CGFloat) + + private func isStrictlyIncreasing(_ values: [CGFloat]) -> Bool { + zip(values, values.dropFirst()).allSatisfy { $0 < $1 } + } + + private func rgba(_ color: Color) -> RGBA { + rgba(UIColor(color)) + } + + private func rgba(_ color: UIColor) -> RGBA { + var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + color.getRed(&r, green: &g, blue: &b, alpha: &a) + return (r, g, b, a) + } + + private func approxEqual(_ lhs: RGBA, _ rhs: RGBA, tolerance: CGFloat = 0.02) -> Bool { + abs(lhs.r - rhs.r) < tolerance + && abs(lhs.g - rhs.g) < tolerance + && abs(lhs.b - rhs.b) < tolerance + } + + private func assertColor(_ color: Color, r: Int, g: Int, b: Int) { + assertRGBA(rgba(color), r: r, g: g, b: b) + } + + private func assertRGBA(_ value: RGBA, r: Int, g: Int, b: Int, tolerance: CGFloat = 0.02) { + #expect(abs(value.r - CGFloat(r) / 255) < tolerance) + #expect(abs(value.g - CGFloat(g) / 255) < tolerance) + #expect(abs(value.b - CGFloat(b) / 255) < tolerance) + } +} diff --git a/ios/App/WebTermTests/TimelineSheetTests.swift b/ios/App/WebTermTests/TimelineSheetTests.swift index 9842600..57c833b 100644 --- a/ios/App/WebTermTests/TimelineSheetTests.swift +++ b/ios/App/WebTermTests/TimelineSheetTests.swift @@ -163,14 +163,16 @@ struct TimelineSheetTests { #expect(TimelineClassStyle.glyph(for: "") == TimelineClassStyle.fallbackGlyph) } - @Test("颜色语义映射:对齐 SessionListScreen 状态色约定;未知 → secondary") + @Test("颜色语义映射:全部走冻结 DS.Palette(无裸色);未知 → textSecondary") func colorMappingIsSemantic() { - #expect(TimelineClassStyle.color(for: "waiting") == .orange) // 列表 waiting 同色 - #expect(TimelineClassStyle.color(for: "stuck") == .red) // 列表 stuck 同色 - #expect(TimelineClassStyle.color(for: "done") == .green) - #expect(TimelineClassStyle.color(for: "tool") == .blue) - #expect(TimelineClassStyle.color(for: "user") == .purple) - #expect(TimelineClassStyle.color(for: "future-class") == .secondary) + // waiting/stuck 复用状态 token(与列表同义);done 复用 statusWorking; + // tool/user 用 timeline 专属 token —— 无一裸色(UX 一致性 finding)。 + #expect(TimelineClassStyle.color(for: "waiting") == DS.Palette.statusWaiting) + #expect(TimelineClassStyle.color(for: "stuck") == DS.Palette.statusStuck) + #expect(TimelineClassStyle.color(for: "done") == DS.Palette.statusWorking) + #expect(TimelineClassStyle.color(for: "tool") == DS.Palette.timelineTool) + #expect(TimelineClassStyle.color(for: "user") == DS.Palette.timelineUser) + #expect(TimelineClassStyle.color(for: "future-class") == DS.Palette.textSecondary) } // MARK: - 行时间格式(镜像 web formatHHMM:24h 墙钟 HH:mm)