Files
web-terminal/ios/App/WebTerm/DesignSystem/Tokens.swift
Yaojia Wang 284cfd193a feat(ios,android): P2 wave, git panel, token UX, per-host WS token, docs
App layer, four sequential slices (a shared .xcodeproj means adding files
regenerates it, so these could not run in parallel):

- token UX end to end: pairing prompts for a token when a host 401s, POST /auth
  validates it, and 204-without-Set-Cookie is correctly read as "this server has
  auth disabled" rather than "authenticated". A host paired before the token was
  turned on recovers by re-pairing in place. Remove-host now exists and finally
  gives PushRegistrar.handleHostRemoved a caller.
- project git panel + worktree lifecycle (T-iOS-32) + claude --resume history —
  the parity gap with Android and the web front end.
- terminal search (T-iOS-33) and voice PTT (T-iOS-31) with an epoch guard so a
  session switch between dictation and confirm cannot inject into the wrong
  session.
- theme + Dynamic Type (T-iOS-34) and web ?join= interop (T-iOS-35). RootView no
  longer hard-locks .preferredColorScheme(.dark).

Also unpins SwiftTerm to 1.15.0 by dropping the local hasActiveSelection that
collided with the upstream one, verified green from a fresh derivedDataPath.

Includes the two HIGH fixes the security review found:
- iOS resolved the WS token host-independently, so a token-gated host sitting
  next to an open one could never open a terminal and no on-screen remedy could
  fix it. Now one transport per host; cross-host leakage is structurally
  impossible since both read paths return only that host's own value.
- Android reported the host's own git-credential 401 (git-ops.ts:108, "Push
  authentication required on the host.") as "your access token is wrong", because
  a blanket 401 mapping ran ahead of the per-route one. Git-write routes are now
  ROUTE_DEFINED and keep the server's message.

And the doc sync: README/ios README no longer claim the client is unmerged on
feat/ios-client, the Clients section finally lists Android, and the plan
checkboxes reflect what is actually built.

iOS 534 app tests + 452 package tests; Android 687 tests.
2026-07-30 15:58:01 +02:00

276 lines
14 KiB
Swift
Raw 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 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 SwiftUIUIKit 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 0255 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.180.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)
}
}
}