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