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, amber-gold accent (desktop-matched) /// continuing the web selection color. /// /// T-iOS-34 · dark-mode-first no longer means dark-mode-only: the app has a real /// theme setting (`AppTheme` / `ThemeStore`, injected once by `RootView`), so /// EVERY color token must resolve sensibly in both schemes. Adaptive tokens are /// built with the private `adaptive(dark:light:)` helper below and their light /// values are contrast-audited in `AppThemeTests` (WCAG 1.4.11, 3:1 floor for /// non-text UI). Known accepted gap: `accent` at its frozen light value #C9892F /// is 2.88:1 on white — fine as a FILL (dark ink on top is 6.2:1) but marginal /// as bare tinted text; the value is pinned by `DesignSystemTests` and left /// unchanged here (reported to the orchestrator instead of silently re-toned). /// /// Vocabulary (all under `DS.`): /// - `Palette` — adaptive `accent` (amber gold, matches desktop) · 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 (amber gold — matches the desktop/web theme) ───────────── // The desktop web/Electron UI uses --accent #E3A64A ("amber gold", // public/style.css:14) on a warm near-neutral dark surface. We match it. // Adaptive: dark = #E3A64A (gold), light = #C9892F (deeper gold = // --accent-2) for adequate contrast on a light background. Used // sparingly — primary actions, selection, active state only. Gold needs // DARK text on top → use `onAccent`, never white/textPrimary. /// 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: 0xE3 / 255.0, green: 0xA6 / 255.0, blue: 0x4A / 255.0, alpha: 1) // #E3A64A --accent : UIColor(red: 0xC9 / 255.0, green: 0x89 / 255.0, blue: 0x2F / 255.0, alpha: 1) // #C9892F --accent-2 } } /// Text/icon color to place ON an accent-filled surface (gold needs dark /// ink for contrast — mirrors web --on-accent #1A1305). static let onAccent = rgb(26, 19, 5) /// Faint accent wash (selection/soft highlight) — web --accent-soft. /// Adaptive (T-iOS-34): the dark wash is the web value verbatim; on a /// light background a 15% wash of the BRIGHT gold reads as nothing, so /// light uses the deeper `--accent-2` gold at a slightly higher alpha. /// Still a wash in both (alpha < 0.3 — never a solid fill). static let accentSoft = adaptive( dark: UIColor(hex: 0xE3A6_4A, alpha: 0.15), light: UIColor(hex: 0xC989_2F, alpha: 0.18) ) // ── Semantic status colors (match the desktop/web status palette) ─── // These are the ONLY status colors. `StatusStyle` pairs each with a // distinct SF Symbol so status is never conveyed by color alone. The // DARK values mirror the web's warm status set (public/style.css:18-20) // byte for byte so iOS and desktop read the same; `waiting` amber // #F5B14C stays distinct from the gold accent #E3A64A (brighter/less // brown), and is only ever a small badge fill, never a large surface. // // T-iOS-34 · the LIGHT values are new. The web chrome is dark-only, so // there was nothing to mirror: each light value is the same hue darkened // until it clears WCAG 1.4.11 (3:1 for non-text UI) against a white // background — the bright dark-mode values sit at 1.96:1 (#46D07F), // 1.60:1 (#F5B14C) and 2.74:1 (#FF6B6B) on white, i.e. unreadable. // `AppThemeTests` pins BOTH the dark bytes and the light contrast floor. /// working — dark #46D07F (web --green) · light #1F8F52 (4.0:1 on white). static let statusWorking = adaptive(dark: 0x46D0_7F, light: 0x1F8F_52) /// waiting / needs-me — dark #F5B14C (web --amber) · light #C25E00 /// (4.2:1; burnt orange keeps it distinct from the light gold accent). static let statusWaiting = adaptive(dark: 0xF5B1_4C, light: 0xC25E_00) /// stuck — dark #FF6B6B (web --red) · light #D22B2B (5.1:1). static let statusStuck = adaptive(dark: 0xFF6B_6B, light: 0xD22B_2B) /// 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 — dark #5E9EFF · light #2C6ED6 (4.8:1 on white; the bright /// blue is 2.63:1 there). static let timelineTool = adaptive(dark: 0x5E9E_FF, light: 0x2C6E_D6) /// user message — dark #AF7BFF · light #7A3BD6 (6.1:1; bright violet is /// 2.81:1). Distinct from accent & tool in both schemes. static let timelineUser = adaptive(dark: 0xAF7B_FF, light: 0x7A3B_D6) // ── 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) // ── Terminal canvas (theme-following as of T-iOS-34) ──────────────── // Was a FIXED warm-dark canvas ("a terminal reads as dark regardless of // app appearance"). With a real light theme that stops being true — a // full-screen near-black slab is the loudest thing on a light UI. The // two schemes now live in `TerminalPalette` (which also vends the // already-resolved set SwiftTerm needs); these stay as the SwiftUI-side // tokens so existing call sites keep compiling and become adaptive. /// Terminal background — dark #100F0D (web --bg) · light #F6F7F9. static let terminalBackground = Color(uiColor: terminalBackgroundUIColor()) /// Terminal foreground — dark #ECE9E3 (web --text) · light #1A1D24. static let terminalForeground = Color(uiColor: terminalForegroundUIColor()) /// Dynamic terminal background. Exposed as `UIColor` because SwiftTerm's /// `nativeBackgroundColor` takes UIKit colors AND flattens them on set /// (see `TerminalPalette`) — the bridge must not go through SwiftUI. static func terminalBackgroundUIColor() -> UIColor { TerminalPalette.dynamicBackground() } /// Dynamic terminal foreground (same rationale as the background). static func terminalForegroundUIColor() -> UIColor { TerminalPalette.dynamicForeground() } /// 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) } /// One scheme-adaptive color from two `0xRRGGBB` values. THE way to add /// an adaptive token — hand-rolling a `UIColor { trait in }` per token /// is how a scheme branch gets forgotten. private static func adaptive(dark: UInt32, light: UInt32) -> Color { adaptive(dark: UIColor(hex: dark), light: UIColor(hex: light)) } /// Same, for values that need a non-opaque alpha (washes). private static func adaptive(dark: UIColor, light: UIColor) -> Color { Color(uiColor: UIColor { trait in trait.userInterfaceStyle == .dark ? dark : light }) } } // 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) } } }