feat(ios): comprehensive iPhone+iPad UX polish — refined-native design system
Freeze a shared design system (DesignSystem/{Tokens,Typography,StatusStyle,
Primitives}.swift): indigo #7C8CFF accent (root .tint), semantic status colors,
2-4-8-12-16-20-24 spacing + sm8/md12/lg16 radii scale, SF Mono tabular numbers,
reduce-motion-gated animations, haptics, reusable StatusBadge/TelemetryChip/Card/
SectionHeader/DSButtonStyle/ContinueLastBanner. Every changed view consumes tokens
— no hardcoded colors/spacing.
Applied across all surfaces (visual only — zero behavior/logic change, all suites
green): session list rows + status system (shape+color, not color-alone) +
telemetry chips + thumbnail placeholders; terminal gate card (≥44pt approve/reject)
+ keybar + reconnect + quick-reply + digest + SwiftTerm accent theme; pairing hero
+ warning tiers + Projects cards + Timeline/Diff; nav chrome + iPad split placeholder
+ privacy shade + motion. Chinese gate copy. UX finding fixed: timeline class colors
now via DS.Palette (+timelineTool/timelineUser tokens).
Design review 8.5/10. Verified: iPhone 16 290 + iPad Pro 11 290 tests green;
packages + integration green; consistency audit ~clean; zero changes under
ios/Packages, src/, public/.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
222
ios/App/WebTerm/DesignSystem/Primitives.swift
Normal file
222
ios/App/WebTerm/DesignSystem/Primitives.swift
Normal file
@@ -0,0 +1,222 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// # Primitives — reusable SwiftUI building blocks (FROZEN public surface)
|
||||
///
|
||||
/// The four component groups compose these by exact name. Each pulls ALL of its
|
||||
/// constants from `DS`/`StatusStyle`/`DS.Typography` — no inline magic. Every
|
||||
/// primitive ships a `#Preview`.
|
||||
|
||||
// MARK: - StatusBadge
|
||||
|
||||
/// Color + distinct SF Symbol (+ optional Chinese label) for one status. The
|
||||
/// VoiceOver label is baked in from `StatusStyle`, so status is conveyed by
|
||||
/// shape, color AND speech. The symbol scales with Dynamic Type.
|
||||
struct StatusBadge: View {
|
||||
let status: DisplayStatus
|
||||
/// Show the Chinese word next to the symbol (list rows usually don't).
|
||||
var showsLabel: Bool = false
|
||||
|
||||
/// Convenience init from the wire enum.
|
||||
init(status: DisplayStatus, showsLabel: Bool = false) {
|
||||
self.status = status
|
||||
self.showsLabel = showsLabel
|
||||
}
|
||||
|
||||
init(claude: ClaudeStatus, showsLabel: Bool = false) {
|
||||
self.init(status: DisplayStatus(claude), showsLabel: showsLabel)
|
||||
}
|
||||
|
||||
private var style: StatusStyle { StatusStyle.style(for: status) }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DS.Space.xs4) {
|
||||
Image(systemName: style.symbolName)
|
||||
.foregroundStyle(style.color)
|
||||
.imageScale(.medium)
|
||||
if showsLabel {
|
||||
Text(style.label)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(style.label)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - TelemetryChip
|
||||
|
||||
/// One pill of monospaced-tabular telemetry (context %, $cost, model, PR…).
|
||||
/// Greys out + desaturates when `isStale`; a `isWarning` chip switches to the
|
||||
/// waiting/amber semantic color for over-threshold context.
|
||||
struct TelemetryChip: View {
|
||||
/// Optional leading SF Symbol.
|
||||
var systemImage: String? = nil
|
||||
let text: String
|
||||
var isStale: Bool = false
|
||||
var isWarning: Bool = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: DS.Space.xs2) {
|
||||
if let systemImage {
|
||||
Image(systemName: systemImage)
|
||||
}
|
||||
Text(text)
|
||||
}
|
||||
.font(DS.Typography.mono(.caption2))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(isWarning ? DS.Palette.statusWaiting : DS.Palette.textSecondary)
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.padding(.vertical, DS.Space.xs2)
|
||||
.background(.quaternary, in: Capsule())
|
||||
.opacity(isStale ? DS.Opacity.stale : 1)
|
||||
.grayscale(isStale ? 1 : 0)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card
|
||||
|
||||
/// Standard card container: card surface + hairline stroke + `md12` radius +
|
||||
/// standard padding. The uniform card spec for rows, grid cells and panels.
|
||||
struct Card<Content: View>: View {
|
||||
/// Inner padding (defaults to `md12`; pass `sm8` for tight rows).
|
||||
var padding: CGFloat = DS.Space.md12
|
||||
@ViewBuilder var content: () -> Content
|
||||
|
||||
init(padding: CGFloat = DS.Space.md12, @ViewBuilder content: @escaping () -> Content) {
|
||||
self.padding = padding
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.padding(padding)
|
||||
.background(DS.Palette.card, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||||
.strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - SectionHeader
|
||||
|
||||
/// A small, secondary section label (Chinese copy passed verbatim by callers).
|
||||
struct SectionHeader: View {
|
||||
let title: String
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(DS.Typography.caption)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DSButtonStyle
|
||||
|
||||
/// The App's button style. `primary` = accent-filled, `secondary` = tinted
|
||||
/// outline, `destructive` = red-filled. Always ≥ `minHitTarget` tall, full
|
||||
/// width, `md12` radius. Press feedback honors Reduce Motion.
|
||||
struct DSButtonStyle: ButtonStyle {
|
||||
enum Kind { case primary, secondary, destructive }
|
||||
var kind: Kind = .primary
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
DSButtonBody(kind: kind, configuration: configuration)
|
||||
}
|
||||
|
||||
/// Nested view so we can read `@Environment` (a `ButtonStyle` cannot).
|
||||
/// Must be as accessible as `DSButtonStyle` (opaque `makeBody` requirement).
|
||||
struct DSButtonBody: View {
|
||||
let kind: Kind
|
||||
let configuration: Configuration
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
|
||||
var body: some View {
|
||||
configuration.label
|
||||
.font(DS.Typography.body.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
|
||||
.foregroundStyle(foreground)
|
||||
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
.overlay(border)
|
||||
.opacity(opacity)
|
||||
.animation(
|
||||
DS.Motion.gated(DS.Motion.fast, reduceMotion: reduceMotion),
|
||||
value: configuration.isPressed
|
||||
)
|
||||
}
|
||||
|
||||
private var foreground: Color {
|
||||
switch kind {
|
||||
case .primary, .destructive: return .white
|
||||
case .secondary: return DS.Palette.accent
|
||||
}
|
||||
}
|
||||
|
||||
private var background: Color {
|
||||
switch kind {
|
||||
case .primary: return DS.Palette.accent
|
||||
case .destructive: return DS.Palette.statusStuck
|
||||
case .secondary: return DS.Palette.card
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private var border: some View {
|
||||
if kind == .secondary {
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md12)
|
||||
.strokeBorder(DS.Palette.accent, lineWidth: DS.Stroke.hairline)
|
||||
}
|
||||
}
|
||||
|
||||
private var opacity: Double {
|
||||
if !isEnabled { return DS.Opacity.pressed }
|
||||
return configuration.isPressed ? DS.Opacity.pressed : 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview("StatusBadge") {
|
||||
VStack(alignment: .leading, spacing: DS.Space.md12) {
|
||||
ForEach(DisplayStatus.allCases, id: \.self) { status in
|
||||
StatusBadge(status: status, showsLabel: true)
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
|
||||
#Preview("TelemetryChip") {
|
||||
HStack(spacing: DS.Space.sm8) {
|
||||
TelemetryChip(text: "ctx 92%", isWarning: true)
|
||||
TelemetryChip(text: "$0.1234")
|
||||
TelemetryChip(systemImage: "cpu", text: "opus")
|
||||
TelemetryChip(text: "PR #7", isStale: true)
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
|
||||
#Preview("Card") {
|
||||
Card {
|
||||
VStack(alignment: .leading, spacing: DS.Space.sm8) {
|
||||
SectionHeader(title: "会话")
|
||||
Text(verbatim: "web-terminal")
|
||||
.font(DS.Typography.headline)
|
||||
Text(verbatim: "2 台设备在看 · 161×50")
|
||||
.dsMetaText()
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
|
||||
#Preview("DSButtonStyle") {
|
||||
VStack(spacing: DS.Space.md12) {
|
||||
Button("新建会话") {}.buttonStyle(DSButtonStyle(kind: .primary))
|
||||
Button("继续上次会话") {}.buttonStyle(DSButtonStyle(kind: .secondary))
|
||||
Button("结束会话") {}.buttonStyle(DSButtonStyle(kind: .destructive))
|
||||
}
|
||||
.tint(DS.Palette.accent)
|
||||
.padding(DS.Space.lg16)
|
||||
}
|
||||
86
ios/App/WebTerm/DesignSystem/StatusStyle.swift
Normal file
86
ios/App/WebTerm/DesignSystem/StatusStyle.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// # Status visuals — the SINGLE source (FROZEN public surface)
|
||||
///
|
||||
/// Every place that shows a session's Claude-Code status (list rows, badges,
|
||||
/// thumbnails, project-detail rows, banners) resolves it through here, so the
|
||||
/// color + shape + label stay identical everywhere. Status is expressed as
|
||||
/// **color AND a distinct SF Symbol** — never color alone (accessibility;
|
||||
/// color-blind users read the shape). Labels are Chinese for VoiceOver + UI.
|
||||
|
||||
/// The seven visual states a status indicator can show. A superset of the wire
|
||||
/// `ClaudeStatus` (working/waiting/idle/unknown/stuck) plus two App-layer
|
||||
/// emphasis states:
|
||||
/// - `pendingApproval` — a tool/plan gate is held server-side ("needs me").
|
||||
/// Outranks status; mirrors `SessionListViewModel.Indicator.pendingApproval`
|
||||
/// (this type does NOT re-implement that priority — callers decide when to
|
||||
/// use it; here it's only its visual identity).
|
||||
/// - `exited` — the session is over (read-only).
|
||||
enum DisplayStatus: CaseIterable, Sendable, Equatable {
|
||||
case working
|
||||
case waiting
|
||||
case idle
|
||||
case stuck
|
||||
case unknown
|
||||
case pendingApproval
|
||||
case exited
|
||||
|
||||
/// Bridge from the wire enum. Pure — no pending/exited emphasis (callers
|
||||
/// supply those explicitly, matching the VM's own indicator priority).
|
||||
init(_ claude: ClaudeStatus) {
|
||||
switch claude {
|
||||
case .working: self = .working
|
||||
case .waiting: self = .waiting
|
||||
case .idle: self = .idle
|
||||
case .stuck: self = .stuck
|
||||
case .unknown: self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved visuals for one status: a semantic color, a DISTINCT SF Symbol
|
||||
/// shape, and a Chinese label (used as the VoiceOver string too). The mapping
|
||||
/// itself is pure — no UIKit, deterministic, unit-testable.
|
||||
struct StatusStyle: Equatable, Sendable {
|
||||
/// Semantic color from `DS.Palette` (never the sole signal — see `symbolName`).
|
||||
let color: Color
|
||||
/// SF Symbol name. Distinct across all seven statuses so shape alone
|
||||
/// disambiguates (color-blind safe).
|
||||
let symbolName: String
|
||||
/// Chinese status word — shown as an optional label and always as the
|
||||
/// accessibility label.
|
||||
let label: String
|
||||
|
||||
/// The frozen mapping. Each status → (color, distinct symbol, 中文 label).
|
||||
static func style(for status: DisplayStatus) -> StatusStyle {
|
||||
switch status {
|
||||
case .working:
|
||||
// solid filled circle — actively running
|
||||
return StatusStyle(color: DS.Palette.statusWorking, symbolName: "circle.fill", label: "运行中")
|
||||
case .waiting:
|
||||
// clock — waiting on something
|
||||
return StatusStyle(color: DS.Palette.statusWaiting, symbolName: "clock.fill", label: "等待中")
|
||||
case .idle:
|
||||
// hollow circle — quiet/empty (gray, distinct from working's fill)
|
||||
return StatusStyle(color: DS.Palette.statusIdle, symbolName: "circle", label: "空闲")
|
||||
case .stuck:
|
||||
// triangle — alarm
|
||||
return StatusStyle(color: DS.Palette.statusStuck, symbolName: "exclamationmark.triangle.fill", label: "卡住")
|
||||
case .unknown:
|
||||
// question mark — no signal yet
|
||||
return StatusStyle(color: DS.Palette.statusUnknown, symbolName: "questionmark.circle", label: "未知")
|
||||
case .pendingApproval:
|
||||
// filled ! circle — "needs me", the single most important prompt
|
||||
return StatusStyle(color: DS.Palette.statusWaiting, symbolName: "exclamationmark.circle.fill", label: "等待审批")
|
||||
case .exited:
|
||||
// checkered flag — session over (mirrors ReconnectBanner's exited icon)
|
||||
return StatusStyle(color: DS.Palette.statusExited, symbolName: "flag.checkered", label: "已退出")
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience bridge from the wire enum.
|
||||
static func style(for claude: ClaudeStatus) -> StatusStyle {
|
||||
style(for: DisplayStatus(claude))
|
||||
}
|
||||
}
|
||||
197
ios/App/WebTerm/DesignSystem/Tokens.swift
Normal file
197
ios/App/WebTerm/DesignSystem/Tokens.swift
Normal file
@@ -0,0 +1,197 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// # WebTerm Design System — token vocabulary (FROZEN public surface)
|
||||
///
|
||||
/// The single source of truth for every visual constant in the App layer.
|
||||
/// Screens/components must reference these tokens — never inline colors,
|
||||
/// spacings, radii, opacities, durations or haptics. Direction: "精致原生"
|
||||
/// (refined native, Apple HIG), dark-mode-first, indigo/violet accent
|
||||
/// continuing the web selection color.
|
||||
///
|
||||
/// Vocabulary (all under `DS.`):
|
||||
/// - `Palette` — adaptive `accent` (indigo) · semantic `status*` colors
|
||||
/// (color is NEVER the only status signal — pair with a symbol,
|
||||
/// see `StatusStyle`) · `surface`/`card`/`hairline` surfaces ·
|
||||
/// `textPrimary`/`textSecondary`/`textTertiary`.
|
||||
/// - `Space` — 2·4·8·12·16·20·24 scale (`xs2`…`xxl24`). No off-scale gaps.
|
||||
/// - `Radius` — `sm8`/`md12`/`lg16`/`pill`.
|
||||
/// - `Stroke` — `hairline` (1pt border width).
|
||||
/// - `Opacity` — `stale`/`exited`/`pressed` dimming multipliers.
|
||||
/// - `Layout` — `minHitTarget` (44pt HIG minimum).
|
||||
/// - `Motion` — `fast`/`base` eased animations + `gated(_:reduceMotion:)`
|
||||
/// which collapses to `nil` under Reduce Motion.
|
||||
/// - `Haptics` — `selection`/`success`/`warning` (`@MainActor`).
|
||||
///
|
||||
/// Companion files: `Typography.swift` (`DS.Typography`), `StatusStyle.swift`
|
||||
/// (`StatusStyle` / `DisplayStatus`), `Primitives.swift` (reusable views).
|
||||
enum DS {
|
||||
|
||||
// MARK: - Palette
|
||||
|
||||
/// Colors. `accent` is asset-free adaptive (no `.xcassets`); the semantic
|
||||
/// status colors use the direction's exact hex so light/dark stay on-brand.
|
||||
enum Palette {
|
||||
|
||||
// ── Accent (indigo/violet — web selection color #7C8CFF) ────────────
|
||||
// Adaptive: dark ≈ #7C8CFF, light ≈ #4F5BD5. Used sparingly — primary
|
||||
// actions, selection, active state only.
|
||||
|
||||
/// The one accent token. Inject once at the root via `.tint(DS.Palette.accent)`.
|
||||
static let accent = Color(uiColor: accentUIColor())
|
||||
|
||||
/// The accent as a dynamic `UIColor`. Exposed so tests can resolve the
|
||||
/// two schemes deterministically (`resolvedColor(with:)`) without going
|
||||
/// through the 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
ios/App/WebTerm/DesignSystem/Typography.swift
Normal file
60
ios/App/WebTerm/DesignSystem/Typography.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
/// # Typography — the SF type ramp (FROZEN public surface)
|
||||
///
|
||||
/// All font choices come from `DS.Typography`. The ramp maps to Apple's
|
||||
/// semantic text styles so everything scales with Dynamic Type (the a11y
|
||||
/// audit's "keep it scalable" point). Numbers/dimensions/cost/`cols×rows`/
|
||||
/// timestamps use `mono(_:)` — SF Mono with tabular figures — so columns line
|
||||
/// up and digits don't jitter as values change (matches the existing
|
||||
/// `TelemetryChips`/`Timeline` convention, now centralized).
|
||||
extension DS {
|
||||
enum Typography {
|
||||
|
||||
// ── Proportional ramp (Dynamic-Type scaling, semantic styles) ───────
|
||||
|
||||
/// Screen hero title.
|
||||
static let largeTitle = Font.largeTitle
|
||||
/// Section / prominent title.
|
||||
static let title = Font.title2
|
||||
/// Emphasis / card heading.
|
||||
static let headline = Font.headline
|
||||
/// Default body text.
|
||||
static let body = Font.body
|
||||
/// Slightly smaller body (secondary actions).
|
||||
static let callout = Font.callout
|
||||
/// Meta / caption text.
|
||||
static let caption = Font.caption
|
||||
|
||||
// ── Monospaced (numbers, dimensions, timestamps) ────────────────────
|
||||
|
||||
/// SF Mono + tabular figures at the given text style (default `.body`).
|
||||
/// Use for anything numeric that must align or not jump: `cols×rows`,
|
||||
/// device/client counts, `$cost`, context %, relative timestamps.
|
||||
static func mono(_ style: Font.TextStyle = .body) -> Font {
|
||||
.system(style, design: .monospaced).monospacedDigit()
|
||||
}
|
||||
|
||||
/// The canonical meta-number font: caption-sized mono + tabular.
|
||||
static let metaMono = mono(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MetaText style
|
||||
|
||||
/// Secondary + monospaced-tabular styling for a row's numeric meta line
|
||||
/// ("N 台设备在看 · 161×50"). Apply via `.dsMetaText()`.
|
||||
struct MetaText: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(DS.Typography.metaMono)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Style a meta/number line as secondary monospaced-tabular text.
|
||||
func dsMetaText() -> some View {
|
||||
modifier(MetaText())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)。可改用手输地址。"
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) 台设备在看"
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "内容已隐藏"
|
||||
}
|
||||
|
||||
@@ -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 = "项目"
|
||||
|
||||
@@ -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 = "从左侧选择一个运行中的会话,或新建一个开始工作。"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user