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.
This commit is contained in:
125
ios/App/WebTerm/DesignSystem/AppTheme.swift
Normal file
125
ios/App/WebTerm/DesignSystem/AppTheme.swift
Normal file
@@ -0,0 +1,125 @@
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
/// T-iOS-34 · 主题设置(跟随系统 / 深色 / 浅色)。
|
||||
///
|
||||
/// 历史:`RootView` 曾**硬锁** `.preferredColorScheme(.dark)`,理由写在注释里
|
||||
/// ——「琥珀金强调色是为暖深色背景设计的,浅底上金字对比不足」。那个理由对
|
||||
/// **当时的 token 取值**成立,但结论下错了:正确做法不是禁掉浅色,而是给每个
|
||||
/// 语义色一个能看清的浅色值(见 `Tokens.swift` 的 light 分支与
|
||||
/// `AppThemeTests` 的 WCAG 断言)。本类型就是解锁后的那个选择。
|
||||
///
|
||||
/// 默认值 = `.dark`,与桌面 web 的 `DEFAULT_SETTINGS.theme = 'dark'`
|
||||
/// (`public/settings.ts:33`)以及此前的硬锁行为一致 —— 老用户升级后观感零变化。
|
||||
enum AppTheme: String, CaseIterable, Equatable, Sendable {
|
||||
/// 跟随系统外观(iOS 设置里的浅色/深色/自动)。
|
||||
case system
|
||||
/// 强制深色(历史默认)。
|
||||
case dark
|
||||
/// 强制浅色。
|
||||
case light
|
||||
|
||||
/// 注入给 SwiftUI 的 `preferredColorScheme` 值。`nil` = 不表态,交给系统
|
||||
/// ——这正是「跟随系统」的实现方式,不能用当前系统值去"快照",否则用户在
|
||||
/// 系统里切换外观时 App 不跟。
|
||||
var colorScheme: ColorScheme? {
|
||||
switch self {
|
||||
case .system: return nil
|
||||
case .dark: return .dark
|
||||
case .light: return .light
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择器行文案(中文,与全 App 一致)。
|
||||
var label: String {
|
||||
switch self {
|
||||
case .system: return "跟随系统"
|
||||
case .dark: return "深色"
|
||||
case .light: return "浅色"
|
||||
}
|
||||
}
|
||||
|
||||
/// 选择器行图标。三档互不相同 —— 颜色之外还有形状(DS 的一贯纪律:
|
||||
/// 语义绝不只靠颜色传达)。
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .system: return "circle.lefthalf.filled"
|
||||
case .dark: return "moon.fill"
|
||||
case .light: return "sun.max.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// 本设置在给定系统外观下的**有效**配色。终端画布等需要"现在到底是深还是浅"
|
||||
/// 的地方用它(`.system` 时答案来自环境,不是常量)。
|
||||
func resolvedScheme(system: ColorScheme) -> ColorScheme {
|
||||
colorScheme ?? system
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 持久化编解码(纯函数)
|
||||
|
||||
/// 存/读的唯一编解码点。读侧**永不抛错**:磁盘上的值是可能被改坏/被旧版本写坏的
|
||||
/// 外部输入,任何不在白名单里的串都退回 `fallback`(而不是崩、也不是悄悄落到
|
||||
/// 「跟随系统」——那会让用户的显式选择变成另一档)。
|
||||
enum AppThemeCodec {
|
||||
/// 未设置 / 脏值时的落点。等于历史硬锁的深色 ⇒ 零回归。
|
||||
static let fallback = AppTheme.dark
|
||||
|
||||
static func decode(_ raw: String?) -> AppTheme {
|
||||
raw.flatMap(AppTheme.init(rawValue:)) ?? fallback
|
||||
}
|
||||
|
||||
static func encode(_ theme: AppTheme) -> String {
|
||||
theme.rawValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 存储接缝
|
||||
|
||||
/// `UserDefaults` 的可测接缝(同 `SecItemShim` 的房规:live 实现零逻辑,
|
||||
/// 逻辑全在可单测的一侧)。**只存主题标识**——这里绝不放任何密级材料
|
||||
/// (令牌/证书只进 Keychain,§1.1)。
|
||||
protocol ThemeDefaults {
|
||||
func themeRaw(forKey key: String) -> String?
|
||||
func setThemeRaw(_ value: String, forKey key: String)
|
||||
}
|
||||
|
||||
/// 直通 `UserDefaults.standard`。无存储属性 ⇒ 无并发状态。
|
||||
struct LiveThemeDefaults: ThemeDefaults {
|
||||
func themeRaw(forKey key: String) -> String? {
|
||||
UserDefaults.standard.string(forKey: key)
|
||||
}
|
||||
|
||||
func setThemeRaw(_ value: String, forKey key: String) {
|
||||
UserDefaults.standard.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ThemeStore
|
||||
|
||||
/// 主题的单一真值。`RootView` 持有它、注入环境;设置页读写它。
|
||||
///
|
||||
/// 写策略:`select` 对**同值**是 no-op(不产生多余写);读只在 init 发生一次,
|
||||
/// 之后内存里的 `theme` 就是真值(`@Observable` 驱动 UI)。
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ThemeStore {
|
||||
/// `UserDefaults` 键。带 App 反查前缀,避免与任何库的键撞车。
|
||||
static let storageKey = "webterm.appearance.theme"
|
||||
|
||||
private(set) var theme: AppTheme
|
||||
|
||||
@ObservationIgnored private let defaults: any ThemeDefaults
|
||||
|
||||
init(defaults: any ThemeDefaults = LiveThemeDefaults()) {
|
||||
self.defaults = defaults
|
||||
self.theme = AppThemeCodec.decode(defaults.themeRaw(forKey: Self.storageKey))
|
||||
}
|
||||
|
||||
/// 选一档。同值直接返回 —— 既省一次写,也避免 `@Observable` 无谓触发重绘。
|
||||
func select(_ next: AppTheme) {
|
||||
guard next != theme else { return }
|
||||
theme = next
|
||||
defaults.setThemeRaw(AppThemeCodec.encode(next), forKey: Self.storageKey)
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,9 @@ struct TelemetryChip: View {
|
||||
.background(.quaternary, in: Capsule())
|
||||
.opacity(isStale ? DS.Opacity.stale : 1)
|
||||
.grayscale(isStale ? 1 : 0)
|
||||
// T-iOS-34 · a `lineLimit(1)` tabular pill cannot wrap, so past a point
|
||||
// extra size only truncates it. Same ceiling as `dsMetaText`.
|
||||
.dynamicTypeSize(...DS.Typography.numericClamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +125,12 @@ struct DSButtonStyle: ButtonStyle {
|
||||
enum Kind { case primary, secondary, destructive }
|
||||
var kind: Kind = .primary
|
||||
|
||||
/// T-iOS-34 · how far a label may shrink before it would rather overflow.
|
||||
/// A gate's two half-width buttons at AX5 hold a word that cannot hyphenate
|
||||
/// ("Approve"); a small scale-down plus wrapping keeps it inside the pill
|
||||
/// instead of bleeding past the rounded edge.
|
||||
fileprivate static let labelMinScale: CGFloat = 0.75
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
DSButtonBody(kind: kind, configuration: configuration)
|
||||
}
|
||||
@@ -137,6 +146,12 @@ struct DSButtonStyle: ButtonStyle {
|
||||
var body: some View {
|
||||
configuration.label
|
||||
.font(DS.Typography.body.weight(.semibold))
|
||||
// Dynamic Type: wrap + center + a bounded shrink, then let the
|
||||
// pill grow taller. `minHeight` (not `height`) is what keeps a
|
||||
// wrapped AX5 label from being clipped to 44pt.
|
||||
.multilineTextAlignment(.center)
|
||||
.minimumScaleFactor(DSButtonStyle.labelMinScale)
|
||||
.padding(.horizontal, DS.Space.sm8)
|
||||
.frame(maxWidth: .infinity, minHeight: DS.Layout.minHitTarget)
|
||||
.foregroundStyle(foreground)
|
||||
.background(background, in: RoundedRectangle(cornerRadius: DS.Radius.md12))
|
||||
|
||||
98
ios/App/WebTerm/DesignSystem/TerminalPalette.swift
Normal file
98
ios/App/WebTerm/DesignSystem/TerminalPalette.swift
Normal file
@@ -0,0 +1,98 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// T-iOS-34 · 终端画布配色 —— **跟随主题**的那一半。
|
||||
///
|
||||
/// 此前终端是"固定暖深色,不随外观变"(对齐桌面)。浅色主题落地后这条不成立:
|
||||
/// 用户选了浅色,却有一整块纯黑画布占满屏,是最刺眼的破绽。所以画布按有效外观
|
||||
/// 取两套值:
|
||||
/// - 深色 = `#100F0D` / `#ECE9E3`(桌面 web `--bg` / `--text`,**逐字节不变**);
|
||||
/// - 浅色 = `#F6F7F9` / `#1A1D24`(镜像 web `public/settings.ts` 的
|
||||
/// `THEMES.light`,iOS 与桌面浅色档观感一致)。
|
||||
///
|
||||
/// caret / selection 用**该档**的 accent 解析值(深色金 `#E3A64A` / 浅色深金
|
||||
/// `#C9892F`),不写死深色档的金。
|
||||
///
|
||||
/// 说明(重要):`SwiftTerm.TerminalView` 在 `nativeBackgroundColor` 的 setter 里
|
||||
/// 就把 UIColor 压成了内部 `terminal.backgroundColor`(`iOSTerminalView.swift:1207`),
|
||||
/// 且没有 `traitCollectionDidChange` 重取 —— 因此**动态 UIColor 只能保证"构建时
|
||||
/// 取对档"**,主题在会话进行中切换必须由视图侧重新 `apply` 一次。本文件提供两种
|
||||
/// 形态供调用点选择:`colors(for:)`(已解析,重套用)与 `dynamic*`(动态 UIColor,
|
||||
/// 构建时取当前 trait)。
|
||||
enum TerminalPalette {
|
||||
|
||||
/// 一档终端画布的四个颜色。全部是**已解析**的具体色(不是动态色),
|
||||
/// 便于直接交给 SwiftTerm,也便于测试逐字节断言。
|
||||
struct Colors: Equatable {
|
||||
let background: UIColor
|
||||
let foreground: UIColor
|
||||
let caret: UIColor
|
||||
let selection: UIColor
|
||||
}
|
||||
|
||||
/// 深色档(桌面对齐,历史值)。
|
||||
private enum Dark {
|
||||
static let background: UInt32 = 0x100F_0D
|
||||
static let foreground: UInt32 = 0xECE9_E3
|
||||
}
|
||||
|
||||
/// 浅色档(镜像 web `THEMES.light`)。
|
||||
private enum Light {
|
||||
static let background: UInt32 = 0xF6F7_F9
|
||||
static let foreground: UInt32 = 0x1A1D_24
|
||||
}
|
||||
|
||||
static func colors(for scheme: ColorScheme) -> Colors {
|
||||
let style = scheme.userInterfaceStyle
|
||||
let accent = DS.Palette.accentUIColor()
|
||||
.resolvedColor(with: UITraitCollection(userInterfaceStyle: style))
|
||||
let isDark = style == .dark
|
||||
return Colors(
|
||||
background: UIColor(hex: isDark ? Dark.background : Light.background),
|
||||
foreground: UIColor(hex: isDark ? Dark.foreground : Light.foreground),
|
||||
caret: accent,
|
||||
selection: accent
|
||||
)
|
||||
}
|
||||
|
||||
/// 动态背景色 —— 取值时机决定档位(SwiftUI/UIKit 的 trait 解析)。
|
||||
static func dynamicBackground() -> UIColor {
|
||||
UIColor { trait in colors(for: trait.colorScheme).background }
|
||||
}
|
||||
|
||||
/// 动态前景色。
|
||||
static func dynamicForeground() -> UIColor {
|
||||
UIColor { trait in colors(for: trait.colorScheme).foreground }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 外观桥(ColorScheme ↔ UIUserInterfaceStyle)
|
||||
|
||||
extension ColorScheme {
|
||||
/// SwiftUI → UIKit。`ColorScheme` 只有 light/dark 两个 case,故是全函数。
|
||||
var userInterfaceStyle: UIUserInterfaceStyle {
|
||||
self == .dark ? .dark : .light
|
||||
}
|
||||
}
|
||||
|
||||
extension UITraitCollection {
|
||||
/// UIKit → SwiftUI。`.unspecified` 按 iOS 的默认外观算作浅色。
|
||||
var colorScheme: ColorScheme {
|
||||
userInterfaceStyle == .dark ? .dark : .light
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hex
|
||||
|
||||
extension UIColor {
|
||||
/// 从 `0xRRGGBB` 构建不透明 sRGB 色。设计 token 的十六进制值在文档/CSS 里
|
||||
/// 就是这个写法,直接用它可以逐字节对照,不必手算 0–1 分量。
|
||||
convenience init(hex: UInt32, alpha: CGFloat = 1) {
|
||||
self.init(
|
||||
red: CGFloat((hex >> 16) & 0xFF) / 255,
|
||||
green: CGFloat((hex >> 8) & 0xFF) / 255,
|
||||
blue: CGFloat(hex & 0xFF) / 255,
|
||||
alpha: alpha
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,16 @@ import UIKit
|
||||
/// (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,
|
||||
@@ -59,21 +69,36 @@ enum DS {
|
||||
/// ink for contrast — mirrors web --on-accent #1A1305).
|
||||
static let onAccent = rgb(26, 19, 5)
|
||||
/// Faint accent wash (selection/soft highlight) — web --accent-soft.
|
||||
static let accentSoft = Color(red: 0xE3 / 255.0, green: 0xA6 / 255.0, blue: 0x4A / 255.0, opacity: 0.15)
|
||||
/// 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. Hex
|
||||
// mirrors the web's warm status set (public/style.css:18-20) 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 accent surface.
|
||||
/// working — #46D07F (web --green).
|
||||
static let statusWorking = rgb(70, 208, 127)
|
||||
/// waiting / needs-me — #F5B14C (web --amber).
|
||||
static let statusWaiting = rgb(245, 177, 76)
|
||||
/// stuck — #FF6B6B (web --red).
|
||||
static let statusStuck = rgb(255, 107, 107)
|
||||
// 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.
|
||||
@@ -86,10 +111,12 @@ enum DS {
|
||||
// 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)
|
||||
/// 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 ────────────────────────────────────────────────
|
||||
|
||||
@@ -106,18 +133,48 @@ enum DS {
|
||||
/// Tertiary label color (de-emphasized detail).
|
||||
static let textTertiary = Color(uiColor: .tertiaryLabel)
|
||||
|
||||
// ── Terminal canvas (fixed warm-dark, matches the desktop terminal) ──
|
||||
// A terminal reads as dark regardless of app appearance (like the
|
||||
// desktop). Values mirror the web chrome: --bg #100F0D / --text #ECE9E3.
|
||||
/// Terminal background — warm near-black #100F0D (web --bg).
|
||||
static let terminalBackground = rgb(16, 15, 13)
|
||||
/// Terminal foreground — warm off-white #ECE9E3 (web --text).
|
||||
static let terminalForeground = rgb(236, 233, 227)
|
||||
// ── 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
|
||||
|
||||
@@ -37,6 +37,23 @@ extension DS {
|
||||
|
||||
/// The canonical meta-number font: caption-sized mono + tabular.
|
||||
static let metaMono = mono(.caption)
|
||||
|
||||
// ── Dynamic Type clamp for dense numerics (T-iOS-34) ────────────────
|
||||
|
||||
/// Upper bound applied to **numeric / meta** text only (telemetry chips,
|
||||
/// `dsMetaText` rows: `ctx 92% · $0.1234 · 161×50`).
|
||||
///
|
||||
/// Prose scales all the way to `.accessibility5` — that is the point of
|
||||
/// Dynamic Type and nothing here caps it. Tabular numerics are different:
|
||||
/// they live in one-line rows next to a status badge, and at AX4/AX5 a
|
||||
/// single chip row becomes three wrapped lines that push the row's real
|
||||
/// content off screen. Capping them at `.accessibility2` (already ~2×
|
||||
/// the default size) keeps the meta line legible AND keeps the row's
|
||||
/// primary text — the session name — visible.
|
||||
///
|
||||
/// Pinned in `DynamicTypeLayoutTests` both as a policy value and
|
||||
/// behaviorally (AX2 and AX5 must measure the same height).
|
||||
static let numericClamp: DynamicTypeSize = .accessibility2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +61,15 @@ extension DS {
|
||||
|
||||
/// Secondary + monospaced-tabular styling for a row's numeric meta line
|
||||
/// ("N 台设备在看 · 161×50"). Apply via `.dsMetaText()`.
|
||||
///
|
||||
/// T-iOS-34 · carries the `numericClamp` so every meta line in the app gets the
|
||||
/// same Dynamic Type ceiling from ONE place (per-screen clamps would drift).
|
||||
struct MetaText: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.font(DS.Typography.metaMono)
|
||||
.foregroundStyle(DS.Palette.textSecondary)
|
||||
.dynamicTypeSize(...DS.Typography.numericClamp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user