Files
web-terminal/ios/App/WebTerm/Components/PlanGateSheet.swift
Yaojia Wang 660a40491a 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/.
2026-07-05 22:00:31 +02:00

119 lines
5.2 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SessionCore
import SwiftUI
/// T-iOS-14 · Three-way sheet for a PLAN gate (B4): Approve+Auto /
/// Approve+Review / Keep Planning mapping mirrors public/tabs.ts:345-347
/// exactly via `GateState.Affordance.clientMessage` (acceptEdits / default /
/// reject; never raw `auto`, and NO allowAutoMode gating plan §7 T-iOS-14).
///
/// Render-only: state lives in `GateViewModel`; every tap reports the
/// affordance PLUS the epoch of the gate THIS sheet rendered (stale-tap
/// first-line guard). Presentation (`.sheet`) wiring is T-iOS-15.
struct PlanGateSheet: View {
/// Mirror of public/tabs.ts:326 plan-gate headline.
static let title = "Claude finished planning — how should it proceed?"
let gate: GateState
let onDecide: (GateState.Affordance, Int) -> Void
private enum Copy {
/// Chinese lead-in above the (web-mirrored) English question.
static let heading = "计划已就绪"
}
var body: some View {
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(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: {
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)
}
}
.buttonStyle(.plain)
.accessibilityIdentifier("plan.decision.\(spec.affordance)")
}
/// Secondary line under each choice: what the wire mode actually does.
static func caption(for affordance: GateState.Affordance) -> String {
switch affordance {
case .approveAuto: return "执行计划编辑自动接受acceptEdits"
case .approveReview: return "执行计划每次编辑需确认default"
case .keepPlanning: return "继续规划,不执行"
case .approve: return "允许本次工具调用"
case .reject: return "拒绝本次工具调用"
}
}
/// 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 .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
}
}
}