import SwiftUI import WireProtocol /// # Primitives — reusable SwiftUI building blocks (FROZEN public surface) /// /// The four component groups compose these by exact name. Each pulls ALL of its /// constants from `DS`/`StatusStyle`/`DS.Typography` — no inline magic. Every /// primitive ships a `#Preview`. // MARK: - StatusBadge /// Color + distinct SF Symbol (+ optional Chinese label) for one status. The /// VoiceOver label is baked in from `StatusStyle`, so status is conveyed by /// shape, color AND speech. The symbol scales with Dynamic Type. struct StatusBadge: View { let status: DisplayStatus /// Show the Chinese word next to the symbol (list rows usually don't). var showsLabel: Bool = false /// Convenience init from the wire enum. init(status: DisplayStatus, showsLabel: Bool = false) { self.status = status self.showsLabel = showsLabel } init(claude: ClaudeStatus, showsLabel: Bool = false) { self.init(status: DisplayStatus(claude), showsLabel: showsLabel) } private var style: StatusStyle { StatusStyle.style(for: status) } var body: some View { HStack(spacing: DS.Space.xs4) { Image(systemName: style.symbolName) .foregroundStyle(style.color) .imageScale(.medium) if showsLabel { Text(style.label) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) } } .accessibilityElement(children: .ignore) .accessibilityLabel(style.label) } } // MARK: - TelemetryChip /// One pill of monospaced-tabular telemetry (context %, $cost, model, PR…). /// Greys out + desaturates when `isStale`; a `isWarning` chip switches to the /// waiting/amber semantic color for over-threshold context. struct TelemetryChip: View { /// Optional leading SF Symbol. var systemImage: String? = nil let text: String var isStale: Bool = false var isWarning: Bool = false var body: some View { HStack(spacing: DS.Space.xs2) { if let systemImage { Image(systemName: systemImage) } Text(text) } .font(DS.Typography.mono(.caption2)) .lineLimit(1) .foregroundStyle(isWarning ? DS.Palette.statusWaiting : DS.Palette.textSecondary) .padding(.horizontal, DS.Space.sm8) .padding(.vertical, DS.Space.xs2) .background(.quaternary, in: Capsule()) .opacity(isStale ? DS.Opacity.stale : 1) .grayscale(isStale ? 1 : 0) } } // MARK: - Card /// Standard card container: card surface + hairline stroke + `md12` radius + /// standard padding. The uniform card spec for rows, grid cells and panels. struct Card: View { /// Inner padding (defaults to `md12`; pass `sm8` for tight rows). var padding: CGFloat = DS.Space.md12 @ViewBuilder var content: () -> Content init(padding: CGFloat = DS.Space.md12, @ViewBuilder content: @escaping () -> Content) { self.padding = padding self.content = content } var body: some View { content() .padding(padding) .background(DS.Palette.card, in: RoundedRectangle(cornerRadius: DS.Radius.md12)) .overlay( RoundedRectangle(cornerRadius: DS.Radius.md12) .strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline) ) } } // MARK: - SectionHeader /// A small, secondary section label (Chinese copy passed verbatim by callers). struct SectionHeader: View { let title: String var body: some View { Text(title) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textSecondary) } } // MARK: - DSButtonStyle /// The App's button style. `primary` = accent-filled, `secondary` = tinted /// outline, `destructive` = red-filled. Always ≥ `minHitTarget` tall, full /// width, `md12` radius. Press feedback honors Reduce Motion. struct DSButtonStyle: ButtonStyle { enum Kind { case primary, secondary, destructive } var kind: Kind = .primary func makeBody(configuration: Configuration) -> some View { DSButtonBody(kind: kind, configuration: configuration) } /// Nested view so we can read `@Environment` (a `ButtonStyle` cannot). /// Must be as accessible as `DSButtonStyle` (opaque `makeBody` requirement). struct DSButtonBody: View { let kind: Kind let configuration: Configuration @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.isEnabled) private var isEnabled var body: some View { configuration.label .font(DS.Typography.body.weight(.semibold)) .frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget) .foregroundStyle(foreground) .background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12)) .overlay(border) .opacity(opacity) .animation( DS.Motion.gated(DS.Motion.fast, reduceMotion: reduceMotion), value: configuration.isPressed ) } private var foreground: Color { switch kind { // 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) }