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:
Yaojia Wang
2026-07-05 22:00:31 +02:00
parent 823432b1c8
commit 660a40491a
25 changed files with 1742 additions and 650 deletions

View File

@@ -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, oldestnewest. 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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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 {