Files
web-terminal/ios/App/WebTerm/DesignSystem/Primitives.swift
Yaojia Wang d01a69eb72 feat(ios): switch accent to desktop amber-gold theme (was indigo)
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).
2026-07-06 07:25:32 +02:00

226 lines
7.5 KiB
Swift
Raw Permalink 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 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)
}