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:
222
ios/App/WebTerm/DesignSystem/Primitives.swift
Normal file
222
ios/App/WebTerm/DesignSystem/Primitives.swift
Normal file
@@ -0,0 +1,222 @@
|
||||
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 {
|
||||
case .primary, .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)
|
||||
}
|
||||
86
ios/App/WebTerm/DesignSystem/StatusStyle.swift
Normal file
86
ios/App/WebTerm/DesignSystem/StatusStyle.swift
Normal file
@@ -0,0 +1,86 @@
|
||||
import SwiftUI
|
||||
import WireProtocol
|
||||
|
||||
/// # Status visuals — the SINGLE source (FROZEN public surface)
|
||||
///
|
||||
/// Every place that shows a session's Claude-Code status (list rows, badges,
|
||||
/// thumbnails, project-detail rows, banners) resolves it through here, so the
|
||||
/// color + shape + label stay identical everywhere. Status is expressed as
|
||||
/// **color AND a distinct SF Symbol** — never color alone (accessibility;
|
||||
/// color-blind users read the shape). Labels are Chinese for VoiceOver + UI.
|
||||
|
||||
/// The seven visual states a status indicator can show. A superset of the wire
|
||||
/// `ClaudeStatus` (working/waiting/idle/unknown/stuck) plus two App-layer
|
||||
/// emphasis states:
|
||||
/// - `pendingApproval` — a tool/plan gate is held server-side ("needs me").
|
||||
/// Outranks status; mirrors `SessionListViewModel.Indicator.pendingApproval`
|
||||
/// (this type does NOT re-implement that priority — callers decide when to
|
||||
/// use it; here it's only its visual identity).
|
||||
/// - `exited` — the session is over (read-only).
|
||||
enum DisplayStatus: CaseIterable, Sendable, Equatable {
|
||||
case working
|
||||
case waiting
|
||||
case idle
|
||||
case stuck
|
||||
case unknown
|
||||
case pendingApproval
|
||||
case exited
|
||||
|
||||
/// Bridge from the wire enum. Pure — no pending/exited emphasis (callers
|
||||
/// supply those explicitly, matching the VM's own indicator priority).
|
||||
init(_ claude: ClaudeStatus) {
|
||||
switch claude {
|
||||
case .working: self = .working
|
||||
case .waiting: self = .waiting
|
||||
case .idle: self = .idle
|
||||
case .stuck: self = .stuck
|
||||
case .unknown: self = .unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved visuals for one status: a semantic color, a DISTINCT SF Symbol
|
||||
/// shape, and a Chinese label (used as the VoiceOver string too). The mapping
|
||||
/// itself is pure — no UIKit, deterministic, unit-testable.
|
||||
struct StatusStyle: Equatable, Sendable {
|
||||
/// Semantic color from `DS.Palette` (never the sole signal — see `symbolName`).
|
||||
let color: Color
|
||||
/// SF Symbol name. Distinct across all seven statuses so shape alone
|
||||
/// disambiguates (color-blind safe).
|
||||
let symbolName: String
|
||||
/// Chinese status word — shown as an optional label and always as the
|
||||
/// accessibility label.
|
||||
let label: String
|
||||
|
||||
/// The frozen mapping. Each status → (color, distinct symbol, 中文 label).
|
||||
static func style(for status: DisplayStatus) -> StatusStyle {
|
||||
switch status {
|
||||
case .working:
|
||||
// solid filled circle — actively running
|
||||
return StatusStyle(color: DS.Palette.statusWorking, symbolName: "circle.fill", label: "运行中")
|
||||
case .waiting:
|
||||
// clock — waiting on something
|
||||
return StatusStyle(color: DS.Palette.statusWaiting, symbolName: "clock.fill", label: "等待中")
|
||||
case .idle:
|
||||
// hollow circle — quiet/empty (gray, distinct from working's fill)
|
||||
return StatusStyle(color: DS.Palette.statusIdle, symbolName: "circle", label: "空闲")
|
||||
case .stuck:
|
||||
// triangle — alarm
|
||||
return StatusStyle(color: DS.Palette.statusStuck, symbolName: "exclamationmark.triangle.fill", label: "卡住")
|
||||
case .unknown:
|
||||
// question mark — no signal yet
|
||||
return StatusStyle(color: DS.Palette.statusUnknown, symbolName: "questionmark.circle", label: "未知")
|
||||
case .pendingApproval:
|
||||
// filled ! circle — "needs me", the single most important prompt
|
||||
return StatusStyle(color: DS.Palette.statusWaiting, symbolName: "exclamationmark.circle.fill", label: "等待审批")
|
||||
case .exited:
|
||||
// checkered flag — session over (mirrors ReconnectBanner's exited icon)
|
||||
return StatusStyle(color: DS.Palette.statusExited, symbolName: "flag.checkered", label: "已退出")
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience bridge from the wire enum.
|
||||
static func style(for claude: ClaudeStatus) -> StatusStyle {
|
||||
style(for: DisplayStatus(claude))
|
||||
}
|
||||
}
|
||||
197
ios/App/WebTerm/DesignSystem/Tokens.swift
Normal file
197
ios/App/WebTerm/DesignSystem/Tokens.swift
Normal file
@@ -0,0 +1,197 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// # WebTerm Design System — token vocabulary (FROZEN public surface)
|
||||
///
|
||||
/// The single source of truth for every visual constant in the App layer.
|
||||
/// Screens/components must reference these tokens — never inline colors,
|
||||
/// spacings, radii, opacities, durations or haptics. Direction: "精致原生"
|
||||
/// (refined native, Apple HIG), dark-mode-first, indigo/violet accent
|
||||
/// continuing the web selection color.
|
||||
///
|
||||
/// Vocabulary (all under `DS.`):
|
||||
/// - `Palette` — adaptive `accent` (indigo) · semantic `status*` colors
|
||||
/// (color is NEVER the only status signal — pair with a symbol,
|
||||
/// see `StatusStyle`) · `surface`/`card`/`hairline` surfaces ·
|
||||
/// `textPrimary`/`textSecondary`/`textTertiary`.
|
||||
/// - `Space` — 2·4·8·12·16·20·24 scale (`xs2`…`xxl24`). No off-scale gaps.
|
||||
/// - `Radius` — `sm8`/`md12`/`lg16`/`pill`.
|
||||
/// - `Stroke` — `hairline` (1pt border width).
|
||||
/// - `Opacity` — `stale`/`exited`/`pressed` dimming multipliers.
|
||||
/// - `Layout` — `minHitTarget` (44pt HIG minimum).
|
||||
/// - `Motion` — `fast`/`base` eased animations + `gated(_:reduceMotion:)`
|
||||
/// which collapses to `nil` under Reduce Motion.
|
||||
/// - `Haptics` — `selection`/`success`/`warning` (`@MainActor`).
|
||||
///
|
||||
/// Companion files: `Typography.swift` (`DS.Typography`), `StatusStyle.swift`
|
||||
/// (`StatusStyle` / `DisplayStatus`), `Primitives.swift` (reusable views).
|
||||
enum DS {
|
||||
|
||||
// MARK: - Palette
|
||||
|
||||
/// Colors. `accent` is asset-free adaptive (no `.xcassets`); the semantic
|
||||
/// status colors use the direction's exact hex so light/dark stay on-brand.
|
||||
enum Palette {
|
||||
|
||||
// ── Accent (indigo/violet — web selection color #7C8CFF) ────────────
|
||||
// Adaptive: dark ≈ #7C8CFF, light ≈ #4F5BD5. Used sparingly — primary
|
||||
// actions, selection, active state only.
|
||||
|
||||
/// The one accent token. Inject once at the root via `.tint(DS.Palette.accent)`.
|
||||
static let accent = Color(uiColor: accentUIColor())
|
||||
|
||||
/// The accent as a dynamic `UIColor`. Exposed so tests can resolve the
|
||||
/// two schemes deterministically (`resolvedColor(with:)`) without going
|
||||
/// through the SwiftUI→UIKit bridge.
|
||||
static func accentUIColor() -> UIColor {
|
||||
UIColor { trait in
|
||||
trait.userInterfaceStyle == .dark
|
||||
? UIColor(red: 0x7C / 255.0, green: 0x8C / 255.0, blue: 0xFF / 255.0, alpha: 1)
|
||||
: UIColor(red: 0x4F / 255.0, green: 0x5B / 255.0, blue: 0xD5 / 255.0, alpha: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Semantic status colors (exact hex from the direction) ───────────
|
||||
// These are the ONLY status colors. `StatusStyle` pairs each with a
|
||||
// distinct SF Symbol so status is never conveyed by color alone.
|
||||
|
||||
/// working — #34C759 (green).
|
||||
static let statusWorking = rgb(52, 199, 89)
|
||||
/// waiting / needs-me — #FF9F0A (amber).
|
||||
static let statusWaiting = rgb(255, 159, 10)
|
||||
/// stuck — #FF453A (red).
|
||||
static let statusStuck = rgb(255, 69, 58)
|
||||
/// idle — quiet secondary gray (distinguished from `unknown` by shape).
|
||||
static let statusIdle = Color.secondary
|
||||
/// unknown / no signal yet — gray.
|
||||
static let statusUnknown = Color.gray
|
||||
/// exited — dimmed secondary (apply `Opacity.exited` on the container).
|
||||
static let statusExited = Color.secondary
|
||||
|
||||
// ── Timeline event classes (T-iOS-24) ──────────────────────────────
|
||||
// Semantic colors for the activity-timeline event classes. `waiting`
|
||||
// and `stuck` reuse the status tokens above (same meaning); `done`
|
||||
// reuses `statusWorking` (a completed run). `tool`/`user` get their own
|
||||
// tokens so nothing in the app hardcodes a raw SwiftUI color.
|
||||
/// tool run — indigo, tied to the app accent family (#5E9EFF-ish).
|
||||
static let timelineTool = rgb(94, 158, 255)
|
||||
/// user message — violet (#AF7BFF), distinct from accent & tool.
|
||||
static let timelineUser = rgb(175, 123, 255)
|
||||
|
||||
// ── Surfaces & text ────────────────────────────────────────────────
|
||||
|
||||
/// Base screen background.
|
||||
static let surface = Color(uiColor: .systemBackground)
|
||||
/// Card / grouped-content background (a step up from `surface`).
|
||||
static let card = Color(uiColor: .secondarySystemBackground)
|
||||
/// Hairline separator/border color.
|
||||
static let hairline = Color(uiColor: .separator)
|
||||
/// Primary label color.
|
||||
static let textPrimary = Color.primary
|
||||
/// Secondary label color (meta, captions).
|
||||
static let textSecondary = Color.secondary
|
||||
/// Tertiary label color (de-emphasized detail).
|
||||
static let textTertiary = Color(uiColor: .tertiaryLabel)
|
||||
|
||||
/// Build an opaque sRGB color from 0–255 components.
|
||||
private static func rgb(_ r: Double, _ g: Double, _ b: Double) -> Color {
|
||||
Color(.sRGB, red: r / 255, green: g / 255, blue: b / 255, opacity: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Space
|
||||
|
||||
/// Spacing scale — 2·4·8·12·16·20·24. Every gap/padding picks one of these;
|
||||
/// there are no in-between values (the audit's 3/5/6/10/14 all round here).
|
||||
enum Space {
|
||||
static let xs2: CGFloat = 2
|
||||
static let xs4: CGFloat = 4
|
||||
static let sm8: CGFloat = 8
|
||||
static let md12: CGFloat = 12
|
||||
static let lg16: CGFloat = 16
|
||||
static let xl20: CGFloat = 20
|
||||
static let xxl24: CGFloat = 24
|
||||
}
|
||||
|
||||
// MARK: - Radius
|
||||
|
||||
/// Corner radii. Legacy 6/10 both fold into `sm8`/`md12`.
|
||||
enum Radius {
|
||||
static let sm8: CGFloat = 8
|
||||
static let md12: CGFloat = 12
|
||||
static let lg16: CGFloat = 16
|
||||
/// Fully-rounded (capsule/pill).
|
||||
static let pill: CGFloat = 999
|
||||
}
|
||||
|
||||
// MARK: - Stroke
|
||||
|
||||
/// Border widths.
|
||||
enum Stroke {
|
||||
/// Hairline card/overlay border.
|
||||
static let hairline: CGFloat = 1
|
||||
}
|
||||
|
||||
// MARK: - Opacity
|
||||
|
||||
/// Dimming multipliers (used with `.opacity()`, sometimes `.grayscale()`).
|
||||
enum Opacity {
|
||||
/// Telemetry gone stale (past its TTL).
|
||||
static let stale: Double = 0.45
|
||||
/// A session that has exited.
|
||||
static let exited: Double = 0.55
|
||||
/// Pressed-state feedback on buttons.
|
||||
static let pressed: Double = 0.72
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
/// Layout constants that are not spacings.
|
||||
enum Layout {
|
||||
/// HIG minimum touch target (44×44pt).
|
||||
static let minHitTarget: CGFloat = 44
|
||||
}
|
||||
|
||||
// MARK: - Motion
|
||||
|
||||
/// Animation tokens. Subtle, eased (~0.18–0.25s). ALWAYS route through
|
||||
/// `gated(_:reduceMotion:)` so Reduce Motion collapses to no motion.
|
||||
enum Motion {
|
||||
static let fastDuration: Double = 0.18
|
||||
static let baseDuration: Double = 0.25
|
||||
|
||||
/// Quick affordance (chips, presses).
|
||||
static let fast = Animation.easeInOut(duration: fastDuration)
|
||||
/// Standard transition (banners, sheets, list changes).
|
||||
static let base = Animation.easeInOut(duration: baseDuration)
|
||||
|
||||
/// Returns `animation` normally, or `nil` (instant, no motion) when
|
||||
/// Reduce Motion is enabled. Callers pass the environment flag.
|
||||
static func gated(_ animation: Animation, reduceMotion: Bool) -> Animation? {
|
||||
reduceMotion ? nil : animation
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Haptics
|
||||
|
||||
/// Tactile feedback for key interactions. `@MainActor` — the underlying
|
||||
/// UIKit generators must be touched on the main thread. Additive to the
|
||||
/// existing `GateViewModel` gate-arrival haptic (different call sites).
|
||||
@MainActor
|
||||
enum Haptics {
|
||||
/// Light selection tap — opening a session, tapping a key.
|
||||
static func selection() {
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
}
|
||||
|
||||
/// Success notification — a gate approve resolved.
|
||||
static func success() {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
||||
}
|
||||
|
||||
/// Warning notification — a destructive/reject decision.
|
||||
static func warning() {
|
||||
UINotificationFeedbackGenerator().notificationOccurred(.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
ios/App/WebTerm/DesignSystem/Typography.swift
Normal file
60
ios/App/WebTerm/DesignSystem/Typography.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
import SwiftUI
|
||||
|
||||
/// # Typography — the SF type ramp (FROZEN public surface)
|
||||
///
|
||||
/// All font choices come from `DS.Typography`. The ramp maps to Apple's
|
||||
/// semantic text styles so everything scales with Dynamic Type (the a11y
|
||||
/// audit's "keep it scalable" point). Numbers/dimensions/cost/`cols×rows`/
|
||||
/// timestamps use `mono(_:)` — SF Mono with tabular figures — so columns line
|
||||
/// up and digits don't jitter as values change (matches the existing
|
||||
/// `TelemetryChips`/`Timeline` convention, now centralized).
|
||||
extension DS {
|
||||
enum Typography {
|
||||
|
||||
// ── Proportional ramp (Dynamic-Type scaling, semantic styles) ───────
|
||||
|
||||
/// Screen hero title.
|
||||
static let largeTitle = Font.largeTitle
|
||||
/// Section / prominent title.
|
||||
static let title = Font.title2
|
||||
/// Emphasis / card heading.
|
||||
static let headline = Font.headline
|
||||
/// Default body text.
|
||||
static let body = Font.body
|
||||
/// Slightly smaller body (secondary actions).
|
||||
static let callout = Font.callout
|
||||
/// Meta / caption text.
|
||||
static let caption = Font.caption
|
||||
|
||||
// ── Monospaced (numbers, dimensions, timestamps) ────────────────────
|
||||
|
||||
/// SF Mono + tabular figures at the given text style (default `.body`).
|
||||
/// Use for anything numeric that must align or not jump: `cols×rows`,
|
||||
/// device/client counts, `$cost`, context %, relative timestamps.
|
||||
static func mono(_ style: Font.TextStyle = .body) -> Font {
|
||||
.system(style, design: .monospaced).monospacedDigit()
|
||||
}
|
||||
|
||||
/// The canonical meta-number font: caption-sized mono + tabular.
|
||||
static let metaMono = mono(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MetaText style
|
||||
|
||||
/// Secondary + monospaced-tabular styling for a row's numeric meta line
|
||||
/// ("N 台设备在看 · 161×50"). Apply via `.dsMetaText()`.
|
||||
struct MetaText: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(DS.Typography.metaMono)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Style a meta/number line as secondary monospaced-tabular text.
|
||||
func dsMetaText() -> some View {
|
||||
modifier(MetaText())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user