Match the desktop/web theme (public/style.css): accent #E3A64A gold (dark) / #C9892F (light) replacing the indigo, +onAccent #1A1305 dark ink for gold-fill buttons, +accentSoft. Status colors realigned to the web warm palette (#46D07F/#F5B14C/#FF6B6B). Terminal canvas fixed to the desktop warm-dark (#100F0D bg / #ECE9E3 fg) with gold caret/selection. DSButtonStyle primary now uses onAccent (dark) instead of white for contrast on gold. All via the single accent token + DS palette — no scattered edits. Verified: iPhone 16 290 + iPad Pro 11 290 tests green (DesignSystemTests assert the exact gold + web-status RGB in both schemes).
226 lines
7.5 KiB
Swift
226 lines
7.5 KiB
Swift
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 {
|
||
// Gold accent fill needs DARK ink for contrast (mirrors web
|
||
// --on-accent), NOT white. Red destructive fill keeps white.
|
||
case .primary: return DS.Palette.onAccent
|
||
case .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)
|
||
}
|