diff --git a/README.md b/README.md index 5d53f72..24423a0 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ TokenStep 是一个 macOS 菜单栏 App,用来本地统计你在 Codex、Claud 下载最新版 DMG,打开后把 `TokenStep.app` 拖进「应用程序」即可使用: -[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.6.dmg) +[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.7.dmg) 也可以从 Release 页面查看所有版本: @@ -32,6 +32,23 @@ TokenStep 已使用 Developer ID 签名并通过 Apple 公证。首次打开时 Windows版本由十七做了移植,欢迎大家前往使用:https://github.com/canyexuanfan/TokenStep-Windows/releases +## 0.2.7 重大更新:引力边界黑洞主题包 + +0.2.7 新增第三套完整皮肤包「引力边界」。它不是给首页换一张壁纸,而是把近距事件视界、象牙白吸积盘和低速引力光流接进 TokenStep 的整套界面。 + +

+ TokenStep 0.2.7 引力边界黑洞主题包浮层 +

+ +- **第三套主题皮肤包**:在 `设置 → 通用 → 主题皮肤包` 中,可在经典、奥德赛和引力边界之间随时切换。 +- **黑洞是第一视觉层**:巨大事件视界占据左上,象牙白吸积盘横贯界面,并保留上方透镜弧和更暗的下方反转弧。 +- **全界面统一换肤**:菜单栏浮层、今日、历史、隐私、设置、更新窗口、Token Island 和两类分享卡全部覆盖。 +- **低成本动态光流**:吸积盘有缓慢相位漂移和轻微呼吸;窗口失焦、截图和 macOS“减少动态效果”状态会自动暂停,最高 24 fps。 +- **原创应用内标记**:主题启用时,TokenStep 的应用内 Logo 会切换为事件视界与吸积盘标记。 +- **更新检查更稳**:GitHub API 被限流时自动使用非 API 的最新 Release 通道;手动检查增加 10 秒防连点,并在双通道都失败时显示可理解的限流恢复时间。 + +主题使用原创生成背景和通用黑洞科学结构,不包含电影 Logo、演员、剧照、飞船或第三方水印。完整说明见 [0.2.7 发布说明](docs/RELEASE_NOTES_0.2.7.md) 与 [引力边界主题实现说明](docs/INTERSTELLAR_THEME_PACK_0.2.7.md)。 + ## 0.2.6 重大更新:检查更新后立即进入安装闭环 0.2.6 修复了从浮层主动检查更新时“查到了,但没有继续弹出更新窗口”的断点,并把下载、验证、替换与自动重启做成可追踪的完整链路。 @@ -85,7 +102,7 @@ TokenStep 第一次从“更换配色”升级为完整的**主题皮肤包系 - 新增奥德赛弓箭阶梯 Logo;用量采用骨金、冷金或余烬橙,绿色只保留给同步成功等状态反馈。 - 关闭排行榜后浮层会自动收短,多来源额度则使用紧凑布局完整展示。 -打开 `设置 → 通用 → 主题皮肤包` 即可切换。完整说明见 [0.2.4 发布说明](docs/RELEASE_NOTES_0.2.4.md),或直接[下载已签名并通过 Apple 公证的最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.6.dmg)。 +打开 `设置 → 通用 → 主题皮肤包` 即可切换。完整说明见 [0.2.4 发布说明](docs/RELEASE_NOTES_0.2.4.md),或直接[下载已签名并通过 Apple 公证的最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.7.dmg)。 ## TokenStep 适合谁? @@ -112,7 +129,7 @@ TokenStep 适合这些人: - 每日目标可设置,默认每天一个亿。 - 打开面板时按设置的新鲜度刷新;后台在接电时最低 15 分钟、电池或低电量模式下最低 30 分钟刷新,并跳过未变化的数据。 - 开机启动,可在设置里关闭。 -- 主题皮肤包:可随时切回五种经典配色,也可使用奥德赛的导演剪辑、爱琴海冷雾、特洛伊火海与灰烬神像四个视觉篇章。 +- 主题皮肤包:可随时切换经典、奥德赛和引力边界;引力边界覆盖全界面并带低成本黑洞光流动效。 - 一键截图分享当前页面。 - 一键生成「昨日 AI 节奏」分享卡,展示 24 小时使用波形、峰值时段和节奏标签。 - Codex / Claude Code 剩余额度可在设置中打开,默认关闭。 @@ -144,7 +161,7 @@ TokenStep 默认只做本地统计。 ## 安装方式 -1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.6.dmg)。 +1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.7.dmg)。 2. 打开 DMG。 3. 把 `TokenStep.app` 拖到「应用程序」。 4. 启动 TokenStep。 @@ -204,7 +221,7 @@ TokenStepSwift/dist/TokenStep.app Developer ID 签名: ```bash -TOKENSTEP_VERSION=0.2.6 \ +TOKENSTEP_VERSION=0.2.7 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ ./script/package_release.sh ``` @@ -212,7 +229,7 @@ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ 签名 + Apple 公证: ```bash -TOKENSTEP_VERSION=0.2.6 \ +TOKENSTEP_VERSION=0.2.7 \ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \ TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \ ./script/package_release.sh --notarize @@ -227,7 +244,7 @@ release/TokenStep-.dmg 维护者说明见 [docs/RELEASE.md](docs/RELEASE.md)。 -0.2.6 发布说明见 [docs/RELEASE_NOTES_0.2.6.md](docs/RELEASE_NOTES_0.2.6.md);0.2.5 动效、模型用量与更新提醒说明见 [docs/RELEASE_NOTES_0.2.5.md](docs/RELEASE_NOTES_0.2.5.md);0.2.4 主题包实现与验收说明见 [docs/ODYSSEY_THEME_PACK_0.2.4.md](docs/ODYSSEY_THEME_PACK_0.2.4.md)。 +0.2.7 发布说明见 [docs/RELEASE_NOTES_0.2.7.md](docs/RELEASE_NOTES_0.2.7.md);引力边界实现说明见 [docs/INTERSTELLAR_THEME_PACK_0.2.7.md](docs/INTERSTELLAR_THEME_PACK_0.2.7.md);0.2.6 更新闭环说明见 [docs/RELEASE_NOTES_0.2.6.md](docs/RELEASE_NOTES_0.2.6.md);0.2.4 奥德赛主题包说明见 [docs/ODYSSEY_THEME_PACK_0.2.4.md](docs/ODYSSEY_THEME_PACK_0.2.4.md)。 ## 开源协议 diff --git a/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift b/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift index accf864..d76fe9b 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Models/UsageModels.swift @@ -858,13 +858,18 @@ struct TokenStepSettings: Codable { } var themePack: TokenStepThemePack { - theme == .voyage ? .odyssey : .classic + switch theme { + case .voyage: return .odyssey + case .eventHorizon: return .interstellar + default: return .classic + } } var activeThemeTitle: String { switch themePack { case .classic: return classicTheme.title case .odyssey: return odysseyChapter.title + case .interstellar: return L("引力边界") } } @@ -937,7 +942,7 @@ struct TokenStepSettings: Codable { self.refreshIntervalSeconds = refreshIntervalSeconds self.historyDays = historyDays self.theme = theme - let fallbackClassicTheme = theme == .voyage ? TokenStepTheme.green : theme + let fallbackClassicTheme = TokenStepTheme.classicCases.contains(theme) ? theme : .green let requestedClassicTheme = classicTheme ?? fallbackClassicTheme self.classicTheme = TokenStepTheme.classicCases.contains(requestedClassicTheme) ? requestedClassicTheme diff --git a/TokenStepSwift/Sources/TokenStepSwift/Services/UpdateService.swift b/TokenStepSwift/Sources/TokenStepSwift/Services/UpdateService.swift index a4521cc..341436d 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Services/UpdateService.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Services/UpdateService.swift @@ -14,7 +14,8 @@ struct AvailableUpdate: Identifiable, Equatable { var assetSize: Int var formattedSize: String { - ByteCountFormatter.string(fromByteCount: Int64(assetSize), countStyle: .file) + guard assetSize > 0 else { return L("发布包") } + return ByteCountFormatter.string(fromByteCount: Int64(assetSize), countStyle: .file) } var noteLines: [String] { @@ -61,6 +62,7 @@ enum UpdateCheckTrigger: String, Equatable { enum UpdateCheckPolicy { static let automaticInterval: TimeInterval = 6 * 60 * 60 static let timerTolerance: TimeInterval = 15 * 60 + static let manualCooldown: TimeInterval = 10 static func shouldStart( trigger: UpdateCheckTrigger, @@ -107,6 +109,7 @@ enum UpdatePresentationPolicy { struct UpdateCheckGate { private(set) var isChecking = false private(set) var lastAttemptAt: Date? + private(set) var lastManualAttemptAt: Date? mutating func begin( trigger: UpdateCheckTrigger, @@ -115,6 +118,11 @@ struct UpdateCheckGate { startupCheckPending: Bool = false ) -> Bool { guard !isChecking else { return false } + if trigger.isManual, + let lastManualAttemptAt, + now.timeIntervalSince(lastManualAttemptAt) < UpdateCheckPolicy.manualCooldown { + return false + } guard UpdateCheckPolicy.shouldStart( trigger: trigger, autoUpdateEnabled: autoUpdateEnabled, @@ -126,6 +134,9 @@ struct UpdateCheckGate { } isChecking = true lastAttemptAt = now + if trigger.isManual { + lastManualAttemptAt = now + } return true } @@ -136,6 +147,8 @@ struct UpdateCheckGate { enum UpdateService { private static let latestReleaseURL = URL(string: "https://api.github.com/repos/Backtthefuture/TokenStep/releases/latest")! + private static let latestReleaseFallbackURL = URL(string: "https://github.com/Backtthefuture/TokenStep/releases/latest")! + private static let releaseDownloadBaseURL = URL(string: "https://github.com/Backtthefuture/TokenStep/releases/download/")! static var currentVersion: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0" @@ -144,7 +157,8 @@ enum UpdateService { static func checkForUpdates( currentVersion: String = Self.currentVersion, session: URLSession = .shared, - releaseURL: URL? = nil + releaseURL: URL? = nil, + fallbackURL: URL? = nil ) async throws -> UpdateCheckResult { var request = URLRequest(url: releaseURL ?? latestReleaseURL) request.cachePolicy = .reloadIgnoringLocalCacheData @@ -152,18 +166,123 @@ enum UpdateService { request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") request.setValue("TokenStep/\(currentVersion)", forHTTPHeaderField: "User-Agent") - let (data, response) = try await session.data(for: request) + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw UpdateError.checkFailed + } + + if (200..<300).contains(http.statusCode) { + return try evaluateReleaseResponse( + data: data, + statusCode: http.statusCode, + currentVersion: currentVersion + ) + } + + let primaryError = responseError(from: http) + do { + return try await checkFallbackLatestRelease( + currentVersion: currentVersion, + session: session, + fallbackURL: fallbackURL + ) + } catch { + throw primaryError + } + } catch let error as UpdateError { + throw error + } catch { + do { + return try await checkFallbackLatestRelease( + currentVersion: currentVersion, + session: session, + fallbackURL: fallbackURL + ) + } catch { + throw UpdateError.checkFailed + } + } + } + + private static func checkFallbackLatestRelease( + currentVersion: String, + session: URLSession, + fallbackURL: URL? + ) async throws -> UpdateCheckResult { + var request = URLRequest(url: fallbackURL ?? latestReleaseFallbackURL) + request.cachePolicy = .reloadIgnoringLocalCacheData + request.timeoutInterval = 15 + request.setValue("text/html,application/xhtml+xml", forHTTPHeaderField: "Accept") + request.setValue("TokenStep/\(currentVersion)", forHTTPHeaderField: "User-Agent") + + let (_, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { throw UpdateError.checkFailed } - - return try evaluateReleaseResponse( - data: data, + return try evaluateFallbackReleaseResponse( + responseURL: http.url, statusCode: http.statusCode, currentVersion: currentVersion ) } + static func evaluateFallbackReleaseResponse( + responseURL: URL?, + statusCode: Int, + currentVersion: String + ) throws -> UpdateCheckResult { + guard (200..<300).contains(statusCode), + let responseURL, + let tagName = releaseTag(from: responseURL), + let releaseVersion = Version(tagName.strippingVersionPrefix), + let installedVersion = Version(currentVersion) + else { + throw UpdateError.checkFailed + } + + guard releaseVersion > installedVersion else { return .upToDate } + let version = tagName.strippingVersionPrefix + let assetName = "TokenStep-\(version).dmg" + let assetURL = releaseDownloadBaseURL + .appendingPathComponent(tagName, isDirectory: true) + .appendingPathComponent(assetName) + + return .available( + AvailableUpdate( + version: version, + tagName: tagName, + title: "TokenStep \(version)", + notes: L("发现新的稳定版本,可直接下载并安装已签名公证的 DMG。"), + pageURL: responseURL, + assetURL: assetURL, + assetName: assetName, + assetSize: 0 + ) + ) + } + + private static func releaseTag(from url: URL) -> String? { + let marker = "/releases/tag/" + guard let range = url.path.range(of: marker) else { return nil } + let encoded = String(url.path[range.upperBound...]) + .trimmingCharacters(in: CharacterSet(charactersIn: "/")) + guard !encoded.isEmpty, !encoded.contains("/") else { return nil } + return encoded.removingPercentEncoding ?? encoded + } + + private static func responseError(from response: HTTPURLResponse) -> UpdateError { + let remaining = response.value(forHTTPHeaderField: "X-RateLimit-Remaining") + let isRateLimited = response.statusCode == 429 + || (response.statusCode == 403 && remaining == "0") + guard isRateLimited else { return .checkFailed } + + let resetAt = response.value(forHTTPHeaderField: "X-RateLimit-Reset") + .flatMap(TimeInterval.init) + .map(Date.init(timeIntervalSince1970:)) + return .rateLimited(resetAt: resetAt) + } + static func evaluateReleaseResponse( data: Data, statusCode: Int, @@ -527,6 +646,7 @@ enum UpdateService { enum UpdateError: LocalizedError, Equatable { case checkFailed + case rateLimited(resetAt: Date?) case missingDMG case downloadFailed case verificationFailed @@ -536,6 +656,13 @@ enum UpdateError: LocalizedError, Equatable { switch self { case .checkFailed: return L("检查更新失败,请稍后再试。") + case let .rateLimited(resetAt): + guard let resetAt else { + return L("GitHub 检查频率暂时受限,请稍后再试。") + } + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return LFormat("GitHub 检查频率暂时受限,预计 %@ 后恢复。", formatter.string(from: resetAt)) case .missingDMG: return L("新版本没有可下载的 DMG。") case .downloadFailed: diff --git a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift index f5dfce9..fae8eb2 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Stores/AppState.swift @@ -423,11 +423,11 @@ final class AppState: ObservableObject { } func setTheme(_ theme: TokenStepTheme) { - if theme == .voyage { - settings.theme = .voyage - } else { + if TokenStepTheme.classicCases.contains(theme) { settings.classicTheme = theme settings.theme = theme + } else { + settings.theme = theme } TokenStepThemeRuntime.apply( settings.theme, @@ -437,7 +437,14 @@ final class AppState: ObservableObject { } func setThemePack(_ pack: TokenStepThemePack) { - settings.theme = pack == .odyssey ? .voyage : settings.classicTheme + switch pack { + case .classic: + settings.theme = settings.classicTheme + case .odyssey: + settings.theme = .voyage + case .interstellar: + settings.theme = .eventHorizon + } TokenStepThemeRuntime.apply( settings.theme, odysseyChapter: settings.odysseyChapter diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Formatters.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Formatters.swift index cf7dc12..2997a2d 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Formatters.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Formatters.swift @@ -109,44 +109,44 @@ extension Color { static var tokenGreen: Color { TokenStepThemeRuntime.palette.accent.color } static var tokenGreenDark: Color { TokenStepThemeRuntime.palette.accentDark.color } static var tokenToggleTint: Color { - TokenStepThemeRuntime.isVoyage ? tokenGreenDark : tokenGreen + TokenStepThemeRuntime.isCinematic ? tokenGreenDark : tokenGreen } static var tokenMint: Color { TokenStepThemeRuntime.palette.accentSoft.color } static var tokenTrack: Color { TokenStepThemeRuntime.palette.track.color } static var tokenLowActivity: Color { TokenStepThemeRuntime.palette.lowActivity.color } static var tokenDivider: Color { TokenStepThemeRuntime.theme.dividerColor } static var tokenHairline: Color { - TokenStepThemeRuntime.isVoyage + TokenStepThemeRuntime.isCinematic ? TokenStepThemeRuntime.palette.accentDark.color.opacity(0.22) : Color.black.opacity(0.055) } static var tokenHairlineStrong: Color { - TokenStepThemeRuntime.isVoyage + TokenStepThemeRuntime.isCinematic ? TokenStepThemeRuntime.palette.accent.color.opacity(0.48) : Color.black.opacity(0.09) } static var tokenInnerHighlight: Color { - TokenStepThemeRuntime.isVoyage + TokenStepThemeRuntime.isCinematic ? Color(red: 240 / 255, green: 209 / 255, blue: 156 / 255).opacity(0.08) : Color.white.opacity(0.42) } static var tokenShadow: Color { - TokenStepThemeRuntime.isVoyage + TokenStepThemeRuntime.isCinematic ? Color.black.opacity(0.34) : Color.black.opacity(0.055) } static var tokenActionText: Color { - TokenStepThemeRuntime.isVoyage + TokenStepThemeRuntime.isCinematic ? Color(red: 22 / 255, green: 18 / 255, blue: 14 / 255) : Color.white } static var tokenMutedFill: Color { - TokenStepThemeRuntime.isVoyage + TokenStepThemeRuntime.isCinematic ? TokenStepThemeRuntime.palette.track.color.opacity(0.52) : TokenStepThemeRuntime.palette.track.color.opacity(0.42) } static var tokenWarning: Color { - TokenStepThemeRuntime.isVoyage + TokenStepThemeRuntime.isCinematic ? Color(red: 218 / 255, green: 139 / 255, blue: 67 / 255) : Color.orange } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift index b7faa5a..242f4d0 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Localization.swift @@ -372,8 +372,10 @@ enum TokenStepLocalization { "远征": "Voyage", "经典": "Classic", "奥德赛": "Odyssey", + "星际": "Interstellar", "原版 TokenStep": "Original TokenStep", "冷雾 · 骨金 · 火星": "Mist · Bone Gold · Embers", + "事件视界 · 象牙光": "Event Horizon · Ivory Light", "导演剪辑": "Director's Cut", "爱琴海冷雾": "Aegean Mist", "特洛伊火海": "Trojan Inferno", @@ -383,9 +385,13 @@ enum TokenStepLocalization { "木马 · 烈火 · 余烬": "Horse · Fire · Embers", "神像 · 石纹 · 灰烬": "Relief · Marble · Ash", "主题皮肤包": "Theme Skin Pack", - "经典配色与奥德赛视觉篇章可随时切换": "Switch between Classic colors and Odyssey chapters anytime", + "经典、奥德赛与引力边界可随时切换": "Switch between Classic, Odyssey, and Event Horizon anytime", "奥德赛视觉篇章": "Odyssey Visual Chapter", "导演剪辑会为不同界面自动分配冷雾、火海与灰烬。": "Director's Cut assigns mist, fire, and ash to the surfaces where they work best.", + "引力边界": "Event Horizon", + "引力边界 · 近距事件视界": "Event Horizon · Close Approach", + "巨大黑洞、象牙白吸积盘与克制电影色;低亮与减少动态时自动静止。": "A monumental black hole, ivory accretion disk, and restrained cinema color; motion pauses in low-light and Reduce Motion states.", + "原创动态主题": "Original Motion Theme", "经典主题色": "Classic Theme Color", "原版 TokenStep 的五种明亮配色": "Five bright palettes from the original TokenStep", "默认": "Default", @@ -669,14 +675,20 @@ enum TokenStepLocalization { "省电策略": "Power saving", "电池或低电量时后台最短 30 分钟": "On battery or Low Power Mode, background refresh is at least 30 minutes", "更新与启动": "Updates and startup", + "发布包": "Release package", + "GitHub 检查频率暂时受限,请稍后再试。": "GitHub update checks are temporarily rate-limited. Please try again later.", + "GitHub 检查频率暂时受限,预计 %@ 后恢复。": "GitHub update checks are rate-limited and should recover after %@.", + "发现新的稳定版本,可直接下载并安装已签名公证的 DMG。": "A new stable release is available. Download and install the signed, notarized DMG.", "自动检查更新": "Check for updates automatically", "榜": "Rank", ], .zhHant: [ "经典": "經典", "奥德赛": "奧德賽", + "星际": "星際", "原版 TokenStep": "原版 TokenStep", "冷雾 · 骨金 · 火星": "冷霧 · 骨金 · 火星", + "事件视界 · 象牙光": "事件視界 · 象牙光", "导演剪辑": "導演剪輯", "爱琴海冷雾": "愛琴海冷霧", "特洛伊火海": "特洛伊火海", @@ -686,9 +698,17 @@ enum TokenStepLocalization { "木马 · 烈火 · 余烬": "木馬 · 烈火 · 餘燼", "神像 · 石纹 · 灰烬": "神像 · 石紋 · 灰燼", "主题皮肤包": "主題皮膚包", - "经典配色与奥德赛视觉篇章可随时切换": "經典配色與奧德賽視覺篇章可隨時切換", + "发布包": "發佈包", + "GitHub 检查频率暂时受限,请稍后再试。": "GitHub 檢查頻率暫時受限,請稍後再試。", + "GitHub 检查频率暂时受限,预计 %@ 后恢复。": "GitHub 檢查頻率暫時受限,預計 %@ 後恢復。", + "发现新的稳定版本,可直接下载并安装已签名公证的 DMG。": "發現新的穩定版本,可直接下載並安裝已簽名公證的 DMG。", + "经典、奥德赛与引力边界可随时切换": "經典、奧德賽與引力邊界可隨時切換", "奥德赛视觉篇章": "奧德賽視覺篇章", "导演剪辑会为不同界面自动分配冷雾、火海与灰烬。": "導演剪輯會為不同介面自動分配冷霧、火海與灰燼。", + "引力边界": "引力邊界", + "引力边界 · 近距事件视界": "引力邊界 · 近距事件視界", + "巨大黑洞、象牙白吸积盘与克制电影色;低亮与减少动态时自动静止。": "巨大黑洞、象牙白吸積盤與克制電影色;低亮與減少動態時自動靜止。", + "原创动态主题": "原創動態主題", "经典主题色": "經典主題色", "原版 TokenStep 的五种明亮配色": "原版 TokenStep 的五種明亮配色", "切换到%@主题包": "切換到%@主題包", diff --git a/TokenStepSwift/Sources/TokenStepSwift/Support/Theme.swift b/TokenStepSwift/Sources/TokenStepSwift/Support/Theme.swift index 3b79b52..2de2551 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Support/Theme.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Support/Theme.swift @@ -54,6 +54,7 @@ struct TokenStepThemePalette { enum TokenStepThemePack: String, CaseIterable, Identifiable, Codable { case classic case odyssey + case interstellar var id: String { rawValue } @@ -61,6 +62,7 @@ enum TokenStepThemePack: String, CaseIterable, Identifiable, Codable { switch self { case .classic: return L("经典") case .odyssey: return L("奥德赛") + case .interstellar: return L("引力边界") } } @@ -68,6 +70,7 @@ enum TokenStepThemePack: String, CaseIterable, Identifiable, Codable { switch self { case .classic: return L("原版 TokenStep") case .odyssey: return L("冷雾 · 骨金 · 火星") + case .interstellar: return L("事件视界 · 象牙光") } } } @@ -201,6 +204,7 @@ enum TokenStepTheme: String, CaseIterable, Identifiable, Codable { case amber case graphite case voyage + case eventHorizon = "event_horizon" static let classicCases: [TokenStepTheme] = [.green, .ocean, .violet, .amber, .graphite] @@ -214,6 +218,7 @@ enum TokenStepTheme: String, CaseIterable, Identifiable, Codable { case .amber: return L("琥珀") case .graphite: return L("石墨") case .voyage: return L("奥德赛") + case .eventHorizon: return L("引力边界") } } @@ -225,23 +230,34 @@ enum TokenStepTheme: String, CaseIterable, Identifiable, Codable { case .amber: return L("温暖") case .graphite: return L("专注") case .voyage: return L("主题皮肤包") + case .eventHorizon: return L("主题皮肤包") } } var colorScheme: ColorScheme { - self == .voyage ? .dark : .light + self == .voyage || self == .eventHorizon ? .dark : .light } var inkColor: Color { - self == .voyage - ? Color(red: 231 / 255, green: 221 / 255, blue: 200 / 255) - : Color(red: 31 / 255, green: 41 / 255, blue: 55 / 255) + switch self { + case .voyage: + return Color(red: 231 / 255, green: 221 / 255, blue: 200 / 255) + case .eventHorizon: + return Color(red: 244 / 255, green: 239 / 255, blue: 226 / 255) + default: + return Color(red: 31 / 255, green: 41 / 255, blue: 55 / 255) + } } var dividerColor: Color { - self == .voyage - ? Color(red: 116 / 255, green: 82 / 255, blue: 49 / 255).opacity(0.58) - : Color.black.opacity(0.06) + switch self { + case .voyage: + return Color(red: 116 / 255, green: 82 / 255, blue: 49 / 255).opacity(0.58) + case .eventHorizon: + return Color(red: 194 / 255, green: 177 / 255, blue: 143 / 255).opacity(0.34) + default: + return Color.black.opacity(0.06) + } } var palette: TokenStepThemePalette { @@ -338,6 +354,24 @@ enum TokenStepTheme: String, CaseIterable, Identifiable, Codable { ) case .voyage: return TokenStepOdysseyChapter.directorsCut.palette + case .eventHorizon: + return TokenStepThemePalette( + canvas: .init(red: 5 / 255, green: 8 / 255, blue: 13 / 255), + surface: .init(red: 18 / 255, green: 21 / 255, blue: 25 / 255), + accent: .init(red: 224 / 255, green: 207 / 255, blue: 169 / 255), + accentDark: .init(red: 251 / 255, green: 241 / 255, blue: 213 / 255), + accentSoft: .init(red: 87 / 255, green: 80 / 255, blue: 67 / 255), + track: .init(red: 47 / 255, green: 49 / 255, blue: 51 / 255), + lowActivity: .init(red: 58 / 255, green: 58 / 255, blue: 57 / 255), + activity1: .init(red: 101 / 255, green: 96 / 255, blue: 86 / 255), + activity2: .init(red: 148 / 255, green: 137 / 255, blue: 113 / 255), + activity3: .init(red: 199 / 255, green: 182 / 255, blue: 146 / 255), + activity4: .init(red: 244 / 255, green: 231 / 255, blue: 198 / 255), + ring1: .init(red: 224 / 255, green: 207 / 255, blue: 169 / 255), + ring2: .init(red: 236 / 255, green: 220 / 255, blue: 183 / 255), + ring3: .init(red: 247 / 255, green: 234 / 255, blue: 204 / 255), + ring4: .init(red: 255 / 255, green: 247 / 255, blue: 224 / 255) + ) } } } @@ -351,9 +385,15 @@ enum TokenStepThemeRuntime { isOdyssey ? activeOdysseyChapter.palette : activeTheme.palette } static var isOdyssey: Bool { activeTheme == .voyage } + static var isInterstellar: Bool { activeTheme == .eventHorizon } + static var isCinematic: Bool { isOdyssey || isInterstellar } static var isVoyage: Bool { isOdyssey } static var odysseyChapter: TokenStepOdysseyChapter { activeOdysseyChapter } - static var themePack: TokenStepThemePack { isOdyssey ? .odyssey : .classic } + static var themePack: TokenStepThemePack { + if isOdyssey { return .odyssey } + if isInterstellar { return .interstellar } + return .classic + } static func apply( _ theme: TokenStepTheme, diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/AgentWorkViews.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/AgentWorkViews.swift index 586f3a2..c0f2163 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/AgentWorkViews.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/AgentWorkViews.swift @@ -88,7 +88,7 @@ struct TodayAgentWorkCard: View { value: "\(selectedActiveHours)/\(period.maximumHours)", detail: AgentWorkCopy.recordedHourDetail, symbol: "clock.fill", - color: TokenStepThemeRuntime.isVoyage + color: TokenStepThemeRuntime.isCinematic ? TokenStepThemeRuntime.palette.activity2.color : Color(red: 0.20, green: 0.52, blue: 0.92) ) @@ -97,7 +97,7 @@ struct TodayAgentWorkCard: View { value: TokenStepFormat.tokens(selectedSevenDayAverage, compact: true), detail: AgentWorkCopy.calendarDayAverage, symbol: "calendar.badge.clock", - color: TokenStepThemeRuntime.isVoyage + color: TokenStepThemeRuntime.isCinematic ? TokenStepThemeRuntime.palette.activity3.color : Color(red: 0.50, green: 0.28, blue: 0.92) ) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift index adb8d71..043493b 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Components.swift @@ -43,7 +43,9 @@ struct TokenStepBackdrop: View { var body: some View { ZStack { Color.tokenCanvas - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isInterstellar { + InterstellarBackdrop(role: role) + } else if TokenStepThemeRuntime.isVoyage { VoyageBackdropAtmosphere(role: role) } else { LinearGradient( @@ -65,7 +67,9 @@ struct TokenStepMark: View { var size: CGFloat = 48 var body: some View { - if let icon = TokenStepAppIconImage.image { + if TokenStepThemeRuntime.isInterstellar { + InterstellarTokenStepMark(size: size) + } else if let icon = TokenStepAppIconImage.image { Image(nsImage: icon) .resizable() .interpolation(.high) @@ -202,7 +206,7 @@ struct UsageRecalibrationNotice: View { } .padding(.horizontal, 14) .padding(.vertical, 11) - .background(Color.tokenSuccess.opacity(TokenStepThemeRuntime.isVoyage ? 0.12 : 0.10), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .background(Color.tokenSuccess.opacity(TokenStepThemeRuntime.isCinematic ? 0.12 : 0.10), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(Color.tokenSuccess.opacity(0.24)) @@ -264,7 +268,7 @@ struct TokenCard: View { .background { ZStack { shape.fill(Color.tokenSurface) - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { LinearGradient( colors: [ Color.tokenGreen.opacity(0.055), @@ -283,6 +287,8 @@ struct TokenCard: View { shape.stroke(Color.tokenHairline) if TokenStepThemeRuntime.isVoyage { VoyageCardOrnament(cornerRadius: 24) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarCardOrnament(cornerRadius: 24) } } } @@ -724,7 +730,7 @@ func contributionColor(tokens: Int, goal: Int) -> Color { } func tokenToolColor(_ tool: String) -> Color { - if TokenStepThemeRuntime.theme == .voyage { + if TokenStepThemeRuntime.isCinematic { let value = tool.lowercased() let palette = TokenStepThemeRuntime.palette if value.contains("codex") { return palette.activity4.color } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/CursorCodeSignalCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/CursorCodeSignalCard.swift index 09a42eb..00b4439 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/CursorCodeSignalCard.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/CursorCodeSignalCard.swift @@ -49,12 +49,12 @@ struct CursorCodeSignalCard: View { } .overlay( RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(TokenStepThemeRuntime.isVoyage ? Color.tokenHairlineStrong : Color(red: 0.42, green: 0.36, blue: 0.82).opacity(0.18)) + .stroke(TokenStepThemeRuntime.isCinematic ? Color.tokenHairlineStrong : Color(red: 0.42, green: 0.36, blue: 0.82).opacity(0.18)) ) } private func modelDot(_ index: Int) -> Color { - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { let colors = [ TokenStepThemeRuntime.palette.activity4.color, TokenStepThemeRuntime.palette.activity3.color, diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/HistoryView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/HistoryView.swift index e8928b3..361111f 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/HistoryView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/HistoryView.swift @@ -38,6 +38,11 @@ struct HistoryView: View { .frame(width: 260, height: 116) .padding(18) .opacity(0.46) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarEventHorizonEmblem() + .frame(width: 260, height: 108) + .padding(18) + .opacity(0.38) } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/InterstellarSkin.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/InterstellarSkin.swift new file mode 100644 index 0000000..70ccbc4 --- /dev/null +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/InterstellarSkin.swift @@ -0,0 +1,365 @@ +import AppKit +import SwiftUI + +enum InterstellarMotionPolicy { + static let preferredFramesPerSecond = 24 + + static func shouldAnimate( + reduceMotion: Bool, + isScreenshotRendering: Bool, + isControlActive: Bool + ) -> Bool { + !reduceMotion && !isScreenshotRendering && isControlActive + } +} + +enum InterstellarArtwork { + private static let hero = load( + resource: "InterstellarEventHorizonHero", + environmentKey: "TOKENSTEP_INTERSTELLAR_HERO_ART_PATH" + ) + private static let quiet = load( + resource: "InterstellarEventHorizonQuiet", + environmentKey: "TOKENSTEP_INTERSTELLAR_QUIET_ART_PATH" + ) + + static func image(quiet prefersQuiet: Bool) -> NSImage? { + prefersQuiet ? (quiet ?? hero) : (hero ?? quiet) + } + + private static func load(resource: String, environmentKey: String) -> NSImage? { + if let path = ProcessInfo.processInfo.environment[environmentKey], + !path.isEmpty, + let image = NSImage(contentsOfFile: path) { + return image + } + if let url = Bundle.main.url(forResource: resource, withExtension: "png"), + let image = NSImage(contentsOf: url) { + return image + } + return NSImage(named: resource) + } +} + +struct InterstellarBackdrop: View { + var role: OdysseySurfaceRole = .generic + var isScreenshotRendering = false + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.controlActiveState) private var controlActiveState + + private var usesQuietArtwork: Bool { + reduceMotion || [.history, .privacy, .settings, .update].contains(role) + } + + private var motionEnabled: Bool { + InterstellarMotionPolicy.shouldAnimate( + reduceMotion: reduceMotion, + isScreenshotRendering: isScreenshotRendering, + isControlActive: controlActiveState != .inactive + ) + } + + var body: some View { + GeometryReader { proxy in + ZStack { + Color(red: 4 / 255, green: 7 / 255, blue: 12 / 255) + + if let artwork = InterstellarArtwork.image(quiet: usesQuietArtwork) { + Image(nsImage: artwork) + .resizable() + .interpolation(.high) + .scaledToFill() + .frame(width: proxy.size.width, height: proxy.size.height) + .clipped() + } else { + InterstellarFallbackPlate() + } + + InterstellarAccretionMotionLayer(isActive: motionEnabled) + + Color.black.opacity(roleVeilOpacity) + + LinearGradient( + colors: [ + Color.black.opacity(0.10), + Color.clear, + Color(red: 4 / 255, green: 7 / 255, blue: 12 / 255).opacity(0.62) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + + LinearGradient( + colors: [Color.clear, Color.black.opacity(bottomVeilOpacity)], + startPoint: .center, + endPoint: .bottom + ) + } + .frame(width: proxy.size.width, height: proxy.size.height) + } + .ignoresSafeArea() + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + private var roleVeilOpacity: Double { + switch role { + case .popover: return 0.16 + case .dashboard: return 0.28 + case .share: return 0.12 + case .history: return 0.43 + case .privacy: return 0.52 + case .settings: return 0.50 + case .update: return 0.42 + case .island: return 0.30 + case .generic: return 0.36 + } + } + + private var bottomVeilOpacity: Double { + switch role { + case .popover, .share: return 0.22 + default: return 0.44 + } + } +} + +private struct InterstellarAccretionMotionLayer: View { + var isActive: Bool + + var body: some View { + TimelineView( + .animation( + minimumInterval: 1 / Double(InterstellarMotionPolicy.preferredFramesPerSecond), + paused: !isActive + ) + ) { timeline in + let time = timeline.date.timeIntervalSinceReferenceDate + GeometryReader { proxy in + let phase = CGFloat(time.truncatingRemainder(dividingBy: 36) / 36) + let breath = 0.5 + 0.5 * sin(time * 0.22) + ZStack { + RadialGradient( + colors: [ + Color(red: 1.0, green: 0.96, blue: 0.84).opacity(0.075 + breath * 0.035), + Color.clear + ], + center: UnitPoint(x: 0.42, y: 0.38), + startRadius: 2, + endRadius: max(proxy.size.width, proxy.size.height) * 0.28 + ) + .blendMode(.screen) + + LinearGradient( + stops: [ + .init(color: .clear, location: 0.00), + .init(color: Color.white.opacity(0.015), location: 0.35), + .init(color: Color(red: 1.0, green: 0.95, blue: 0.80).opacity(0.13), location: 0.49), + .init(color: Color.white.opacity(0.018), location: 0.64), + .init(color: .clear, location: 1.00) + ], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: proxy.size.width * 0.72, height: max(2, proxy.size.height * 0.012)) + .blur(radius: 2.4) + .offset( + x: -proxy.size.width * 0.36 + phase * proxy.size.width * 1.08, + y: proxy.size.height * 0.015 + ) + .blendMode(.screen) + + LinearGradient( + colors: [Color.clear, Color.white.opacity(0.055), Color.clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: proxy.size.width * 0.34, height: 1) + .offset( + x: proxy.size.width * (0.18 - phase * 0.42), + y: proxy.size.height * 0.055 + ) + .blendMode(.screen) + } + .frame(width: proxy.size.width, height: proxy.size.height) + } + } + .opacity(isActive ? 1 : 0) + } +} + +private struct InterstellarFallbackPlate: View { + var body: some View { + GeometryReader { proxy in + ZStack { + LinearGradient( + colors: [Color.black, Color(red: 8 / 255, green: 13 / 255, blue: 20 / 255)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + Circle() + .fill(Color.black) + .frame(width: proxy.size.width * 0.72) + .offset(x: -proxy.size.width * 0.30, y: -proxy.size.height * 0.18) + Capsule() + .fill( + LinearGradient( + colors: [Color.clear, Color(red: 1.0, green: 0.95, blue: 0.82), Color.clear], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: proxy.size.width * 1.15, height: 4) + .shadow(color: Color.white.opacity(0.55), radius: 14) + } + } + } +} + +struct InterstellarPanelBackground: View { + var opacity: Double = 0.70 + var cornerRadius: CGFloat = 18 + + var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + shape + .fill(Color(red: 8 / 255, green: 11 / 255, blue: 16 / 255).opacity(opacity)) + .overlay(shape.stroke(Color.tokenHairlineStrong.opacity(0.82), lineWidth: 1)) + .overlay(alignment: .topLeading) { + LinearGradient( + colors: [Color.tokenGreenDark.opacity(0.70), Color.tokenGreen.opacity(0.08), Color.clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: 128, height: 1) + .padding(.leading, 18) + } + .shadow(color: Color.black.opacity(0.30), radius: 18, y: 9) + } +} + +struct InterstellarWindowFrame: View { + var inset: CGFloat = 8 + + var body: some View { + GeometryReader { proxy in + let shape = RoundedRectangle(cornerRadius: 20, style: .continuous) + ZStack { + shape + .stroke(Color.tokenHairlineStrong.opacity(0.78), lineWidth: 1) + .padding(inset) + ForEach(0..<4, id: \.self) { index in + let x = index < 2 ? inset + 16 : proxy.size.width - inset - 16 + let y = index.isMultiple(of: 2) ? inset + 1 : proxy.size.height - inset - 1 + Capsule() + .fill(Color.tokenGreenDark.opacity(0.66)) + .frame(width: min(120, proxy.size.width * 0.16), height: 1) + .position(x: x + (index < 2 ? 48 : -48), y: y) + } + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +struct InterstellarCardOrnament: View { + var cornerRadius: CGFloat = 24 + + var body: some View { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .stroke(Color.tokenInnerHighlight.opacity(0.72), lineWidth: 1) + .padding(1) + .overlay(alignment: .topLeading) { + Capsule() + .fill(Color.tokenGreenDark.opacity(0.64)) + .frame(width: 82, height: 1) + .padding(.leading, 20) + } + .allowsHitTesting(false) + } +} + +struct InterstellarEventHorizonEmblem: View { + var body: some View { + GeometryReader { proxy in + ZStack { + Ellipse() + .stroke(Color.tokenGreenDark.opacity(0.42), lineWidth: 2) + .frame(width: proxy.size.width * 0.92, height: proxy.size.height * 0.24) + .rotationEffect(.degrees(-5)) + Circle() + .fill(Color.black.opacity(0.96)) + .overlay(Circle().stroke(Color.tokenGreenDark.opacity(0.54), lineWidth: 1.5)) + .frame(width: min(proxy.size.width, proxy.size.height) * 0.54) + Capsule() + .fill( + LinearGradient( + colors: [Color.clear, Color.tokenGreenDark.opacity(0.9), Color.clear], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: proxy.size.width, height: 2) + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +struct InterstellarTokenStepMark: View { + var size: CGFloat + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: size * 0.27, style: .continuous) + .fill(Color(red: 7 / 255, green: 10 / 255, blue: 15 / 255)) + .overlay( + RoundedRectangle(cornerRadius: size * 0.27, style: .continuous) + .stroke(Color.tokenGreen.opacity(0.50), lineWidth: max(0.8, size * 0.02)) + ) + Ellipse() + .stroke(Color.tokenGreenDark.opacity(0.86), lineWidth: max(1.2, size * 0.055)) + .frame(width: size * 0.72, height: size * 0.20) + .shadow(color: Color.tokenGreenDark.opacity(0.30), radius: size * 0.08) + Circle() + .fill(Color.black) + .overlay(Circle().stroke(Color.tokenGreenDark.opacity(0.72), lineWidth: max(0.7, size * 0.025))) + .frame(width: size * 0.34, height: size * 0.34) + Capsule() + .fill(Color.tokenGreenDark) + .frame(width: size * 0.68, height: max(1, size * 0.035)) + } + .frame(width: size, height: size) + } +} + +struct InterstellarThemePreview: View { + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 13, style: .continuous) + .fill( + LinearGradient( + colors: [Color.black, Color(red: 11 / 255, green: 16 / 255, blue: 24 / 255)], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + Ellipse() + .stroke(Color.tokenGreenDark.opacity(0.88), lineWidth: 3) + .frame(width: 50, height: 13) + .offset(x: -7) + Circle() + .fill(Color.black) + .frame(width: 20, height: 20) + .offset(x: -8) + Capsule() + .fill(Color.tokenGreenDark) + .frame(width: 51, height: 2) + .offset(x: 2) + } + .overlay(RoundedRectangle(cornerRadius: 13, style: .continuous).stroke(Color.tokenGreen.opacity(0.48))) + } +} diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift index 9aea5c3..9e17d27 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/MainWindowView.swift @@ -81,6 +81,8 @@ struct MainWindowView: View { .overlay { if TokenStepThemeRuntime.isVoyage { VoyageWindowFrame(inset: 8) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarWindowFrame(inset: 8) } } .environment(\.colorScheme, appState.settings.theme.colorScheme) @@ -163,7 +165,7 @@ struct MainWindowView: View { .background { ZStack { Color.tokenSurface.opacity(0.94) - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { LinearGradient( colors: [Color.tokenGreen.opacity(0.085), Color.clear, Color.tokenGreenDark.opacity(0.03)], startPoint: .leading, @@ -291,13 +293,13 @@ private struct DashboardTabButton: View { .frame(height: 28) .background( selected - ? (TokenStepThemeRuntime.isVoyage ? Color.tokenGreen.opacity(0.16) : Color.tokenSurface) + ? (TokenStepThemeRuntime.isCinematic ? Color.tokenGreen.opacity(0.16) : Color.tokenSurface) : Color.clear, in: RoundedRectangle(cornerRadius: 7, style: .continuous) ) .overlay( RoundedRectangle(cornerRadius: 7, style: .continuous) - .stroke(selected && TokenStepThemeRuntime.isVoyage ? Color.tokenHairlineStrong : Color.clear) + .stroke(selected && TokenStepThemeRuntime.isCinematic ? Color.tokenHairlineStrong : Color.clear) ) .shadow(color: selected ? Color.tokenShadow : .clear, radius: 3, y: 1) } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverAgentWorkTable.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverAgentWorkTable.swift index bb736fe..26b7ec4 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverAgentWorkTable.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverAgentWorkTable.swift @@ -8,7 +8,7 @@ struct PopoverAgentWorkTable: View { } var body: some View { - let voyage = TokenStepThemeRuntime.isVoyage + let voyage = TokenStepThemeRuntime.isCinematic return VStack(alignment: .leading, spacing: 0) { HStack { Text(L("Agent 用量")) @@ -44,7 +44,7 @@ struct PopoverAgentWorkTable: View { } private var header: some View { - let voyage = TokenStepThemeRuntime.isVoyage + let voyage = TokenStepThemeRuntime.isCinematic return HStack(spacing: 10) { Text(L("来源")).frame(maxWidth: .infinity, alignment: .leading) Text(L("Token")).frame(width: voyage ? 88 : 72, alignment: .trailing) @@ -54,7 +54,7 @@ struct PopoverAgentWorkTable: View { } private func rowView(_ row: AgentWorkSource) -> some View { - let voyage = TokenStepThemeRuntime.isVoyage + let voyage = TokenStepThemeRuntime.isCinematic return HStack(alignment: .center, spacing: 10) { HStack(alignment: .center, spacing: 6) { Circle() diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverFooterView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverFooterView.swift index 4e092dc..8aca293 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverFooterView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverFooterView.swift @@ -6,7 +6,7 @@ struct PopoverFooterView: View { var body: some View { Group { - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { odysseyFooter } else { classicFooter diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverModelUsageSection.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverModelUsageSection.swift index c138996..0fabac2 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverModelUsageSection.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverModelUsageSection.swift @@ -121,7 +121,7 @@ struct PopoverModelUsageSection: View { } } } - .padding(.horizontal, TokenStepThemeRuntime.isVoyage ? 14 : 12) + .padding(.horizontal, TokenStepThemeRuntime.isCinematic ? 14 : 12) .padding(.vertical, 8) .frame(maxWidth: .infinity, alignment: .topLeading) } @@ -129,7 +129,7 @@ struct PopoverModelUsageSection: View { private func header(mismatch: Bool) -> some View { HStack(spacing: 7) { Text(L("今日模型")) - .font((TokenStepThemeRuntime.isVoyage ? Font.callout : Font.caption).weight(.heavy)) + .font((TokenStepThemeRuntime.isCinematic ? Font.callout : Font.caption).weight(.heavy)) .foregroundStyle(Color.tokenInk) Spacer(minLength: 4) if mismatch { @@ -178,7 +178,7 @@ struct PopoverModelUsageSection: View { .frame(width: 44, alignment: .trailing) .monospacedDigit() } - .font(.system(size: TokenStepThemeRuntime.isVoyage ? 10.5 : 10, weight: .semibold)) + .font(.system(size: TokenStepThemeRuntime.isCinematic ? 10.5 : 10, weight: .semibold)) .foregroundStyle(Color.tokenInk.opacity(0.82)) GeometryReader { proxy in @@ -212,7 +212,7 @@ struct PopoverModelUsageSection: View { guard let slot = row.colorSlot else { return Color.secondary.opacity(0.72) } - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { switch slot { case 0: return .tokenGreen case 1: return TokenStepThemeRuntime.palette.activity3.color diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverQuotaCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverQuotaCard.swift index 8064312..61c0f42 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverQuotaCard.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverQuotaCard.swift @@ -4,7 +4,7 @@ struct PopoverQuotaCard: View { @EnvironmentObject private var appState: AppState var body: some View { - let voyage = TokenStepThemeRuntime.isVoyage + let voyage = TokenStepThemeRuntime.isCinematic return VStack(alignment: .leading, spacing: voyage ? 12 : 10) { HStack(spacing: 8) { Text(L("订阅额度")) @@ -57,7 +57,7 @@ struct PopoverQuotaCard: View { } private func quotaChip(_ quota: ProviderQuota) -> some View { - let voyage = TokenStepThemeRuntime.isVoyage + let voyage = TokenStepThemeRuntime.isCinematic return VStack(alignment: .leading, spacing: voyage ? 4 : 6) { Text(quota.provider.displayName) .font((voyage ? Font.system(size: 11.5) : Font.caption2).weight(.heavy)) @@ -80,7 +80,7 @@ struct PopoverQuotaCard: View { } private func quotaRow(_ window: QuotaWindow) -> some View { - let voyage = TokenStepThemeRuntime.isVoyage + let voyage = TokenStepThemeRuntime.isCinematic return VStack(alignment: .leading, spacing: voyage ? 3 : 4) { HStack { Text(window.kind.shortTitle) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTodayRingCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTodayRingCard.swift index 16d6621..d506433 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTodayRingCard.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTodayRingCard.swift @@ -5,7 +5,7 @@ struct PopoverTodayRingCard: View { var body: some View { let lap = appState.todayLap - let voyage = TokenStepThemeRuntime.isVoyage + let voyage = TokenStepThemeRuntime.isCinematic let ringSize: CGFloat = voyage ? 164 : 118 let lineWidth: CGFloat = voyage ? 14 : 12 @@ -21,12 +21,22 @@ struct PopoverTodayRingCard: View { } ZStack { - if voyage { + if TokenStepThemeRuntime.isVoyage { Circle() .fill(Color.tokenCanvas.opacity(0.42)) .padding(12) .blur(radius: 0.2) VoyageBowProgressView(progress: lap.currentLapProgress, lineWidth: lineWidth, color: lap.ringColor) + } else if TokenStepThemeRuntime.isInterstellar { + Circle() + .fill(Color.black.opacity(0.52)) + .padding(12) + ProgressRingView(progress: lap.currentLapProgress, lineWidth: lineWidth, color: lap.ringColor) + .overlay { + InterstellarEventHorizonEmblem() + .padding(30) + .opacity(0.50) + } } else { ProgressRingView(progress: lap.currentLapProgress, lineWidth: lineWidth, color: lap.ringColor) } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift index 34117c3..3c17181 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Popover/PopoverTokenRankCard.swift @@ -273,21 +273,21 @@ struct PopoverTokenRankCard: View { VStack(alignment: .leading, spacing: 4) { Text(L("全榜今日消耗")) .font(.system(size: 10.5, weight: .heavy)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk.opacity(0.62) : Color.white.opacity(0.62)) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.62) : Color.white.opacity(0.62)) Text(totalRankTokensText) .font(.system(size: 22, weight: .heavy, design: .rounded)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk : Color.white) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk : Color.white) .monospacedDigit() if let count = appState.tokenRank?.totalRankedUsers, count > 0 { Text(LFormat("%d 人参榜", count)) .font(.caption2.weight(.bold)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk.opacity(0.62) : Color.white.opacity(0.62)) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.62) : Color.white.opacity(0.62)) } } .padding(10) .frame(maxWidth: .infinity, alignment: .leading) .background( - TokenStepThemeRuntime.isVoyage + TokenStepThemeRuntime.isCinematic ? Color.tokenGreen.opacity(0.11) : Color(red: 0.12, green: 0.18, blue: 0.14), in: RoundedRectangle(cornerRadius: 12, style: .continuous) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift index 21edd9e..c6c2d5e 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift @@ -9,20 +9,22 @@ struct PopoverPanelView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { header - if TokenStepThemeRuntime.isVoyage { - odysseyContent + if TokenStepThemeRuntime.isCinematic { + cinematicContent } else { classicColumns } notices PopoverFooterView() - .padding(.horizontal, TokenStepThemeRuntime.isVoyage ? 22 : 16) - .padding(.top, TokenStepThemeRuntime.isVoyage ? 14 : 12) - .padding(.bottom, TokenStepThemeRuntime.isVoyage ? 20 : 14) + .padding(.horizontal, TokenStepThemeRuntime.isCinematic ? 22 : 16) + .padding(.top, TokenStepThemeRuntime.isCinematic ? 14 : 12) + .padding(.bottom, TokenStepThemeRuntime.isCinematic ? 20 : 14) } .frame(width: 900) .background { - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isInterstellar { + InterstellarBackdrop(role: .popover, isScreenshotRendering: isScreenshotRendering) + } else if TokenStepThemeRuntime.isVoyage { OdysseyPopoverBackdrop( isMotionSurfaceActive: odysseyMotionSurfaceVisible, isScreenshotRendering: isScreenshotRendering @@ -40,6 +42,8 @@ struct PopoverPanelView: View { .overlay { if TokenStepThemeRuntime.isVoyage { OdysseyPopoverWindowFrame(inset: 7) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarWindowFrame(inset: 7) } } .environment(\.colorScheme, appState.settings.theme.colorScheme) @@ -57,8 +61,8 @@ struct PopoverPanelView: View { private var header: some View { HStack(spacing: 10) { TokenStepBrandLockup( - markSize: TokenStepThemeRuntime.isVoyage ? 34 : 28, - titleSize: TokenStepThemeRuntime.isVoyage ? 20 : 17 + markSize: TokenStepThemeRuntime.isCinematic ? 34 : 28, + titleSize: TokenStepThemeRuntime.isCinematic ? 20 : 17 ) Spacer() HStack(spacing: 6) { @@ -86,10 +90,10 @@ struct PopoverPanelView: View { ) } } - .padding(.horizontal, TokenStepThemeRuntime.isVoyage ? 26 : 16) - .frame(height: TokenStepThemeRuntime.isVoyage ? 72 : 48) + .padding(.horizontal, TokenStepThemeRuntime.isCinematic ? 26 : 16) + .frame(height: TokenStepThemeRuntime.isCinematic ? 72 : 48) .background { - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { LinearGradient( colors: [Color.black.opacity(0.30), Color.tokenCanvas.opacity(0.12), Color.black.opacity(0.16)], startPoint: .leading, @@ -98,7 +102,7 @@ struct PopoverPanelView: View { } } .overlay(alignment: .bottom) { - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { LinearGradient( colors: [Color.tokenGreen.opacity(0.58), Color.tokenDivider.opacity(0.34), Color.clear], startPoint: .leading, @@ -110,23 +114,23 @@ struct PopoverPanelView: View { } } - private var odysseyContent: some View { + private var cinematicContent: some View { VStack(spacing: 10) { HStack(alignment: .top, spacing: 10) { PopoverTodayRingCard() .frame(width: 250, height: odysseyTopCardHeight) - .background(OdysseyPopoverSectionBackground(opacity: 0.16)) + .background(cinematicSectionBackground(opacity: TokenStepThemeRuntime.isInterstellar ? 0.66 : 0.16)) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) centralUsageCard .frame(minWidth: 330, maxWidth: .infinity, minHeight: odysseyTopCardHeight, maxHeight: odysseyTopCardHeight) - .background(OdysseyPopoverSectionBackground(opacity: 0.46)) + .background(cinematicSectionBackground(opacity: TokenStepThemeRuntime.isInterstellar ? 0.70 : 0.46)) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) if appState.showsQuotaColumn { PopoverQuotaCard() .frame(width: 260, height: odysseyTopCardHeight) - .background(OdysseyPopoverSectionBackground(opacity: 0.54)) + .background(cinematicSectionBackground(opacity: TokenStepThemeRuntime.isInterstellar ? 0.74 : 0.54)) .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) } } @@ -134,7 +138,7 @@ struct PopoverPanelView: View { if appState.shouldShowAgentWorkRank, appState.agentWorkRankIdentity != nil { PopoverTokenRankCard(layout: .ribbon) .frame(height: 76) - .background(OdysseyPopoverSectionBackground(opacity: 0.58, cornerRadius: 16)) + .background(cinematicSectionBackground(opacity: TokenStepThemeRuntime.isInterstellar ? 0.76 : 0.58, cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } } @@ -174,6 +178,15 @@ struct PopoverPanelView: View { private var odysseyTopCardHeight: CGFloat { 288 } + @ViewBuilder + private func cinematicSectionBackground(opacity: Double, cornerRadius: CGFloat = 18) -> some View { + if TokenStepThemeRuntime.isInterstellar { + InterstellarPanelBackground(opacity: opacity, cornerRadius: cornerRadius) + } else { + OdysseyPopoverSectionBackground(opacity: opacity, cornerRadius: cornerRadius) + } + } + private var showsModelUsageSection: Bool { switch PopoverModelUsageRows.state(from: appState.today) { case .hidden: return false @@ -182,7 +195,7 @@ struct PopoverPanelView: View { } private var centralUsageCard: some View { - let voyage = TokenStepThemeRuntime.isVoyage + let voyage = TokenStepThemeRuntime.isCinematic return VStack(spacing: 0) { PopoverAgentWorkTable() .frame(height: voyage ? (showsModelUsageSection ? 128 : odysseyTopCardHeight) : nil) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/PrivacyView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/PrivacyView.swift index c6060fc..00b9de7 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/PrivacyView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/PrivacyView.swift @@ -24,6 +24,11 @@ struct PrivacyView: View { .frame(width: 132, height: 104) .padding(14) .opacity(0.48) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarEventHorizonEmblem() + .frame(width: 150, height: 78) + .padding(18) + .opacity(0.44) } } @@ -67,7 +72,7 @@ struct PrivacyView: View { } .overlay( RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(TokenStepThemeRuntime.isVoyage ? Color.tokenHairlineStrong : Color(red: 0.42, green: 0.36, blue: 0.82).opacity(0.16)) + .stroke(TokenStepThemeRuntime.isCinematic ? Color.tokenHairlineStrong : Color(red: 0.42, green: 0.36, blue: 0.82).opacity(0.16)) ) } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/ScreenshotCaptureViews.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/ScreenshotCaptureViews.swift index 36881a2..79914e3 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/ScreenshotCaptureViews.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/ScreenshotCaptureViews.swift @@ -16,6 +16,8 @@ struct DashboardScreenshotView: View { if TokenStepThemeRuntime.isVoyage { VoyageWindowFrame(inset: 8) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarWindowFrame(inset: 8) } } .frame(width: 1000) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsControls.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsControls.swift index 8be510b..ee0f28a 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsControls.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsControls.swift @@ -35,7 +35,7 @@ enum SettingsBadgeStyle { case ok, warn, off, l1, l2, l3 var foreground: Color { - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { switch self { case .ok, .l1: return Color.tokenSuccess case .warn: return Color.tokenWarning @@ -54,7 +54,7 @@ enum SettingsBadgeStyle { } var background: Color { - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { switch self { case .ok, .l1: return Color.tokenSuccess.opacity(0.12) case .warn: return Color.tokenWarning.opacity(0.12) @@ -73,7 +73,7 @@ enum SettingsBadgeStyle { } var border: Color { - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { switch self { case .ok, .l1: return Color.tokenSuccess.opacity(0.30) case .warn: return Color.tokenWarning.opacity(0.34) @@ -163,7 +163,7 @@ struct SettingsSectionCard: View { } private var sectionBackground: Color { - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { return Color.tokenSurface } switch tint ?? badgeStyle { @@ -174,7 +174,7 @@ struct SettingsSectionCard: View { } private var sectionBorder: Color { - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { switch tint ?? badgeStyle { case .l2: return Color.tokenGreen.opacity(0.30) case .l3: return Color.tokenGreenDark.opacity(0.34) @@ -258,7 +258,7 @@ struct SettingsCard: View { ZStack { RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(Color.tokenSurface) - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { LinearGradient( colors: [Color.tokenGreen.opacity(0.05), Color.clear, Color.tokenGreenDark.opacity(0.025)], startPoint: .topLeading, @@ -273,6 +273,8 @@ struct SettingsCard: View { RoundedRectangle(cornerRadius: 24, style: .continuous).stroke(Color.tokenHairline) if TokenStepThemeRuntime.isVoyage { VoyageCardOrnament(cornerRadius: 24) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarCardOrnament(cornerRadius: 24) } } } @@ -306,7 +308,7 @@ struct ThemeSwatchButton: View { Button(action: action) { VStack(spacing: 7) { ZStack { - if theme == .voyage { + if theme == .voyage || theme == .eventHorizon { TokenStepMark(size: 38) .shadow(color: theme.palette.accentDark.color.opacity(selected ? 0.24 : 0.12), radius: 8, x: 0, y: 4) } else { @@ -329,7 +331,7 @@ struct ThemeSwatchButton: View { if selected && theme != .voyage { Image(systemName: "checkmark") .font(.system(size: 13, weight: .heavy)) - .foregroundStyle(theme == .voyage ? Color.tokenActionText : Color.white) + .foregroundStyle(theme == .voyage || theme == .eventHorizon ? Color.tokenActionText : Color.white) } } @@ -442,6 +444,8 @@ struct ThemePackOptionButton: View { } } .overlay(RoundedRectangle(cornerRadius: 13, style: .continuous).stroke(odysseyChapter.palette.accent.color.opacity(0.46))) + case .interstellar: + InterstellarThemePreview() } } } @@ -608,7 +612,7 @@ struct TokenStepSwitchToggleStyle: ToggleStyle { .fill( configuration.isOn ? Color.tokenToggleTint - : Color.tokenTrack.opacity(TokenStepThemeRuntime.isVoyage ? 0.82 : 0.72) + : Color.tokenTrack.opacity(TokenStepThemeRuntime.isCinematic ? 0.82 : 0.72) ) Circle() .fill(configuration.isOn ? Color.tokenInk : Color.tokenInk.opacity(0.62)) diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsGeneralPane.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsGeneralPane.swift index 17ab397..14550e8 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsGeneralPane.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Settings/SettingsGeneralPane.swift @@ -66,7 +66,7 @@ struct SettingsGeneralPane: View { SettingsSectionCard( title: L("主题皮肤包"), - subtitle: L("经典配色与奥德赛视觉篇章可随时切换") + subtitle: L("经典、奥德赛与引力边界可随时切换") ) { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { @@ -123,6 +123,32 @@ struct SettingsGeneralPane: View { } } .transition(.opacity.combined(with: .move(edge: .top))) + } else if appState.settings.themePack == .interstellar { + HStack(spacing: 14) { + InterstellarEventHorizonEmblem() + .frame(width: 126, height: 58) + + VStack(alignment: .leading, spacing: 3) { + Text(L("引力边界 · 近距事件视界")) + .font(.callout.weight(.heavy)) + .foregroundStyle(Color.tokenInk) + Text(L("巨大黑洞、象牙白吸积盘与克制电影色;低亮与减少动态时自动静止。")) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 8) + + Text(L("原创动态主题")) + .font(.caption.weight(.heavy)) + .foregroundStyle(Color.tokenGreenDark) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(Color.tokenGreen.opacity(0.12), in: Capsule()) + .overlay(Capsule().stroke(Color.tokenHairlineStrong)) + } + .transition(.opacity.combined(with: .move(edge: .top))) } else { HStack { VStack(alignment: .leading, spacing: 2) { diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift index c81a716..579615e 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/SettingsView.swift @@ -62,6 +62,8 @@ struct SettingsView: View { .overlay { if TokenStepThemeRuntime.isVoyage { VoyageWindowFrame(inset: 8) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarWindowFrame(inset: 8) } } } @@ -140,6 +142,11 @@ struct SettingsView: View { .frame(width: 124, height: 72) .opacity(0.38) .offset(x: -92, y: 3) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarEventHorizonEmblem() + .frame(width: 136, height: 58) + .opacity(0.42) + .offset(x: -92, y: 3) } } } @@ -246,13 +253,13 @@ private struct DashboardSettingsTab: View { .frame(height: 28) .background( selected - ? (TokenStepThemeRuntime.isVoyage ? Color.tokenGreen.opacity(0.16) : Color.tokenSurface) + ? (TokenStepThemeRuntime.isCinematic ? Color.tokenGreen.opacity(0.16) : Color.tokenSurface) : Color.clear, in: RoundedRectangle(cornerRadius: 7, style: .continuous) ) .overlay( RoundedRectangle(cornerRadius: 7, style: .continuous) - .stroke(selected && TokenStepThemeRuntime.isVoyage ? Color.tokenHairlineStrong : Color.clear) + .stroke(selected && TokenStepThemeRuntime.isCinematic ? Color.tokenHairlineStrong : Color.clear) ) .shadow(color: selected ? Color.tokenShadow : .clear, radius: 3, y: 1) } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareDailyCardView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareDailyCardView.swift index 6ce30a0..0a6f5e4 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareDailyCardView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareDailyCardView.swift @@ -63,6 +63,8 @@ struct ShareDailyCardView: View { if TokenStepThemeRuntime.isVoyage { VoyageWindowFrame(inset: 8) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarWindowFrame(inset: 8) } } .frame(width: 600, height: 980) @@ -94,6 +96,11 @@ struct ShareDailyCardView: View { .frame(width: 230, height: 210) .opacity(0.24) .offset(x: 18, y: 12) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarEventHorizonEmblem() + .frame(width: 250, height: 150) + .opacity(0.34) + .offset(x: 18, y: 12) } HStack(alignment: .center, spacing: 18) { @@ -105,6 +112,13 @@ struct ShareDailyCardView: View { Group { if TokenStepThemeRuntime.isVoyage { VoyageBowProgressView(progress: lap.currentLapProgress, lineWidth: 20, color: lap.color) + } else if TokenStepThemeRuntime.isInterstellar { + ProgressRingView(progress: lap.currentLapProgress, lineWidth: 20, color: lap.color) + .overlay { + InterstellarEventHorizonEmblem() + .padding(42) + .opacity(0.52) + } } else { ProgressRingView(progress: lap.currentLapProgress, lineWidth: 20, color: lap.color) } @@ -445,7 +459,7 @@ private struct ShareCardSurface: View { .background { ZStack { shape.fill(Color.tokenSurface) - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { LinearGradient( colors: [Color.tokenGreen.opacity(0.055), Color.clear, Color.tokenGreenDark.opacity(0.025)], startPoint: .topLeading, @@ -460,6 +474,8 @@ private struct ShareCardSurface: View { shape.stroke(Color.tokenHairline) if TokenStepThemeRuntime.isVoyage { VoyageCardOrnament(cornerRadius: cornerRadius) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarCardOrnament(cornerRadius: cornerRadius) } } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareRhythmCardView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareRhythmCardView.swift index d329fe9..b763e2b 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareRhythmCardView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/Share/ShareRhythmCardView.swift @@ -18,6 +18,9 @@ struct ShareRhythmCardView: View { if TokenStepThemeRuntime.isVoyage { VoyageBackdropAtmosphere(role: .share) .opacity(0.72) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarBackdrop(role: .share, isScreenshotRendering: true) + .opacity(0.88) } VStack(spacing: 14) { @@ -35,6 +38,8 @@ struct ShareRhythmCardView: View { if TokenStepThemeRuntime.isVoyage { VoyageWindowFrame(inset: 8) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarWindowFrame(inset: 8) } } .frame(width: 600, height: 840) @@ -57,7 +62,7 @@ struct ShareRhythmCardView: View { .foregroundStyle(palette.accent) Text(weekdayText) .font(.callout.weight(.bold)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk.opacity(0.56) : Color.white.opacity(0.56)) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.56) : Color.white.opacity(0.56)) } } } @@ -66,7 +71,7 @@ struct ShareRhythmCardView: View { VStack(spacing: 8) { Text(L("昨日 AI 节奏")) .font(.system(size: 31, weight: .black, design: .rounded)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk : Color.white) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk : Color.white) .lineLimit(1) HStack(alignment: .center, spacing: 12) { @@ -92,7 +97,7 @@ struct ShareRhythmCardView: View { Text(rhythm.primaryTag.shareLine) .font(.callout.weight(.bold)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk.opacity(0.66) : Color.white.opacity(0.66)) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.66) : Color.white.opacity(0.66)) .lineLimit(1) .minimumScaleFactor(0.76) } @@ -108,12 +113,12 @@ struct ShareRhythmCardView: View { .background(palette.accent.opacity(0.12), in: Circle()) Text(LFormat("峰值 %@", peakWindowText)) .font(.headline.weight(.black)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk : Color.white) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk : Color.white) .lineLimit(1) Spacer() Text(LFormat("峰值 %@", TokenStepFormat.tokens(rhythm.peakTokens, compact: true))) .font(.callout.weight(.heavy)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk.opacity(0.72) : Color.white.opacity(0.72)) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.72) : Color.white.opacity(0.72)) .lineLimit(1) } .padding(.horizontal, 18) @@ -137,12 +142,12 @@ struct ShareRhythmCardView: View { VStack(spacing: 3) { Text(L("昨日 Token")) .font(.headline.weight(.black)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk.opacity(0.70) : Color.white.opacity(0.70)) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.70) : Color.white.opacity(0.70)) Text(TokenStepFormat.tokens(day.totalTokens, compact: true)) .font(.system(size: 49, weight: .black, design: .rounded)) .foregroundStyle( LinearGradient( - colors: [palette.accent, TokenStepThemeRuntime.isVoyage ? Color.tokenInk : Color.white], + colors: [palette.accent, TokenStepThemeRuntime.isCinematic ? Color.tokenInk : Color.white], startPoint: .top, endPoint: .bottom ) @@ -185,7 +190,7 @@ struct ShareRhythmCardView: View { Text(L("不上传对话")) } .font(.caption.weight(.heavy)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk.opacity(0.54) : Color.white.opacity(0.54)) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.54) : Color.white.opacity(0.54)) .frame(maxWidth: .infinity) .frame(height: 38) .background(Color.black.opacity(0.20), in: Capsule()) @@ -314,7 +319,7 @@ private struct RhythmAxisLabel: View { VStack(spacing: 4) { Text(AgentWorkCopy.hourLabel(hour)) .font(.caption.weight(.heavy)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk.opacity(0.48) : Color.white.opacity(0.48)) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.48) : Color.white.opacity(0.48)) if let symbol { Image(systemName: symbol) .font(.system(size: 16, weight: .bold)) @@ -344,7 +349,7 @@ private struct RhythmBottomMetric: View { Text(value) .font(.system(size: 24, weight: .black, design: .rounded)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk : Color.white) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk : Color.white) .minimumScaleFactor(0.68) .lineLimit(1) } @@ -392,7 +397,7 @@ private struct RhythmPeakMarker: View { .stroke(color.opacity(0.36), style: StrokeStyle(lineWidth: 1.5, dash: [5, 7])) Circle() - .fill(TokenStepThemeRuntime.isVoyage ? Color.tokenInk : Color.white) + .fill(TokenStepThemeRuntime.isCinematic ? Color.tokenInk : Color.white) .frame(width: 11, height: 11) .overlay(Circle().stroke(color, lineWidth: 3)) .shadow(color: color.opacity(0.8), radius: 14, x: 0, y: 0) @@ -564,7 +569,7 @@ private struct RhythmCardBackdrop: View { .rotationEffect(.degrees(-23)) .offset(x: 250, y: -48) RhythmGridShape(columns: 11, rows: 14) - .stroke(TokenStepThemeRuntime.isVoyage ? Color.tokenInk.opacity(0.026) : Color.white.opacity(0.026), lineWidth: 1) + .stroke(TokenStepThemeRuntime.isCinematic ? Color.tokenInk.opacity(0.026) : Color.white.opacity(0.026), lineWidth: 1) } } } @@ -577,7 +582,7 @@ private struct RhythmCardPalette { var panel: Color static func palette(for rhythm: DailyRhythm) -> RhythmCardPalette { - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { return RhythmCardPalette( background: [ Color(red: 9 / 255, green: 10 / 255, blue: 9 / 255), diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/TodayDashboardCards.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/TodayDashboardCards.swift index f5bb8cf..4fb0087 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/TodayDashboardCards.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/TodayDashboardCards.swift @@ -10,6 +10,13 @@ struct TodayHeroCard: View { ZStack { if TokenStepThemeRuntime.isVoyage { VoyageBowProgressView(progress: lap.currentLapProgress, lineWidth: 16, color: lap.ringColor) + } else if TokenStepThemeRuntime.isInterstellar { + ProgressRingView(progress: lap.currentLapProgress, lineWidth: 16, color: lap.ringColor) + .overlay { + InterstellarEventHorizonEmblem() + .padding(34) + .opacity(0.50) + } } else { ProgressRingView(progress: lap.currentLapProgress, lineWidth: 16, color: lap.ringColor) } @@ -93,6 +100,11 @@ struct TodayAgentIntensityCard: View { .frame(width: 94, height: 76) .padding(14) .opacity(0.54) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarEventHorizonEmblem() + .frame(width: 116, height: 58) + .padding(14) + .opacity(0.44) } } } @@ -172,6 +184,11 @@ struct TodayHourlyCard: View { .frame(width: 180, height: 86) .padding(12) .opacity(0.46) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarEventHorizonEmblem() + .frame(width: 180, height: 70) + .padding(12) + .opacity(0.34) } } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/TokenIslandView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/TokenIslandView.swift index 057fa7f..9249cd8 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/TokenIslandView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/TokenIslandView.swift @@ -90,7 +90,7 @@ struct TokenIslandRingView: View { Text(TokenStepFormat.tokens(tokens, compact: true, language: language)) .font(.system(size: 13, weight: .heavy, design: .rounded)) - .foregroundStyle(TokenStepThemeRuntime.isVoyage ? Color.tokenInk : Color.white) + .foregroundStyle(TokenStepThemeRuntime.isCinematic ? Color.tokenInk : Color.white) .monospacedDigit() .lineLimit(1) .minimumScaleFactor(0.74) @@ -101,18 +101,23 @@ struct TokenIslandRingView: View { .background { ZStack { Color.black - if TokenStepThemeRuntime.isVoyage { + if TokenStepThemeRuntime.isCinematic { LinearGradient( colors: [Color.tokenCanvas, Color.tokenGreen.opacity(0.16), Color.tokenCanvas], startPoint: .leading, endPoint: .trailing ) } + if TokenStepThemeRuntime.isInterstellar { + InterstellarEventHorizonEmblem() + .padding(.horizontal, 5) + .opacity(0.42) + } } } .clipShape(Capsule()) - .overlay(Capsule().stroke(TokenStepThemeRuntime.isVoyage ? Color.tokenHairlineStrong : Color.clear)) - .shadow(color: TokenStepThemeRuntime.isVoyage ? Color.tokenGreen.opacity(0.16) : .clear, radius: 8) + .overlay(Capsule().stroke(TokenStepThemeRuntime.isCinematic ? Color.tokenHairlineStrong : Color.clear)) + .shadow(color: TokenStepThemeRuntime.isCinematic ? Color.tokenGreen.opacity(0.16) : .clear, radius: 8) .id("\(theme.id)-\(language.resolved.id)") } } @@ -191,8 +196,15 @@ private struct TokenIslandExpandedView: View { private var ring: some View { ZStack { - if TokenStepThemeRuntime.theme == .voyage { + if TokenStepThemeRuntime.isVoyage { VoyageBowProgressView(progress: lap.currentLapProgress, lineWidth: 9, color: lap.ringColor) + } else if TokenStepThemeRuntime.isInterstellar { + ProgressRingView(progress: lap.currentLapProgress, lineWidth: 9, color: lap.ringColor) + .overlay { + InterstellarEventHorizonEmblem() + .padding(18) + .opacity(0.48) + } } else { ProgressRingView(progress: lap.currentLapProgress, lineWidth: 9, color: lap.ringColor) } @@ -236,6 +248,8 @@ private struct TokenIslandExpandedSurface: View { shape.stroke(Color.tokenHairlineStrong) if TokenStepThemeRuntime.isVoyage { VoyageCardOrnament(cornerRadius: TokenIslandMetrics.expandedCornerRadius) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarCardOrnament(cornerRadius: TokenIslandMetrics.expandedCornerRadius) } } } @@ -259,6 +273,8 @@ private struct TokenIslandExpandedBackground: View { ) if TokenStepThemeRuntime.isVoyage { VoyageBackdropAtmosphere(role: .island) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarBackdrop(role: .island) } } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/UpdateWindowView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/UpdateWindowView.swift index a73233a..c08d8c1 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/UpdateWindowView.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/UpdateWindowView.swift @@ -21,6 +21,8 @@ struct UpdateWindowView: View { .overlay { if TokenStepThemeRuntime.isVoyage { VoyageWindowFrame(inset: 8) + } else if TokenStepThemeRuntime.isInterstellar { + InterstellarWindowFrame(inset: 8) } } .environment(\.colorScheme, appState.settings.theme.colorScheme) @@ -122,6 +124,14 @@ struct UpdateWindowView: View { } .frame(height: 8) } + .overlay(alignment: .trailing) { + if TokenStepThemeRuntime.isInterstellar { + InterstellarEventHorizonEmblem() + .frame(width: 72, height: 24) + .offset(y: 12) + .opacity(0.55) + } + } } } diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/VoyageSkin.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/VoyageSkin.swift index 5ce6d6a..00902da 100644 --- a/TokenStepSwift/Sources/TokenStepSwift/Views/VoyageSkin.swift +++ b/TokenStepSwift/Sources/TokenStepSwift/Views/VoyageSkin.swift @@ -259,18 +259,22 @@ struct TokenStepBrandLockup: View { HStack(spacing: max(8, markSize * 0.30)) { TokenStepMark(size: markSize) - VStack(alignment: .leading, spacing: TokenStepThemeRuntime.isVoyage && showsEdition ? 1 : 0) { + VStack(alignment: .leading, spacing: TokenStepThemeRuntime.isCinematic && showsEdition ? 1 : 0) { Text("TokenStep") .font(.system(size: titleSize, weight: .heavy, design: .rounded)) .foregroundStyle(Color.tokenInk) - .tracking(TokenStepThemeRuntime.isVoyage ? 0.2 : 0) + .tracking(TokenStepThemeRuntime.isCinematic ? 0.2 : 0) - if TokenStepThemeRuntime.isVoyage && showsEdition { + if TokenStepThemeRuntime.isCinematic && showsEdition { HStack(spacing: 5) { Rectangle() .fill(Color.tokenGreen) .frame(width: 12, height: 1) - Text(TokenStepThemeRuntime.odysseyChapter.editionLabel) + Text( + TokenStepThemeRuntime.isInterstellar + ? "GRAVITY · EVENT HORIZON" + : TokenStepThemeRuntime.odysseyChapter.editionLabel + ) .font(.system(size: max(7, titleSize * 0.47), weight: .bold, design: .serif)) .tracking(1.35) .foregroundStyle(Color.tokenGreenDark.opacity(0.84)) diff --git a/TokenStepSwift/Tests/Fixtures/PopoverPanelRender.swift b/TokenStepSwift/Tests/Fixtures/PopoverPanelRender.swift index 795a401..24ed94b 100644 --- a/TokenStepSwift/Tests/Fixtures/PopoverPanelRender.swift +++ b/TokenStepSwift/Tests/Fixtures/PopoverPanelRender.swift @@ -13,9 +13,12 @@ struct PopoverPanelRender { NSApp.applicationIconImage = icon } - let theme = ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_THEME"] == "voyage" - ? TokenStepTheme.voyage - : TokenStepTheme.green + let theme: TokenStepTheme + switch ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_THEME"] { + case "voyage": theme = .voyage + case "event_horizon": theme = .eventHorizon + default: theme = .green + } let chapter = ProcessInfo.processInfo.environment["TOKENSTEP_ODYSSEY_CHAPTER"] .flatMap(TokenStepOdysseyChapter.init(rawValue:)) ?? .aegeanMist @@ -47,7 +50,7 @@ struct PopoverPanelRender { let output = URL( fileURLWithPath: ProcessInfo.processInfo.environment["TOKENSTEP_POPOVER_RENDER_PATH"] - ?? "/tmp/tokenstep-popover-v0.2.4.png" + ?? "/tmp/tokenstep-popover-v0.2.7.png" ) try FileManager.default.createDirectory( at: output.deletingLastPathComponent(), @@ -75,7 +78,7 @@ struct PopoverPanelRender { NSLocalizedDescriptionKey: "Unexpected render size: \(bitmap.pixelsWide)x\(bitmap.pixelsHigh)" ]) } - print("v0.2.5-a2: \(bitmap.pixelsWide)x\(bitmap.pixelsHigh) -> \(output.path)") + print("v0.2.7: \(bitmap.pixelsWide)x\(bitmap.pixelsHigh) -> \(output.path)") } private static func validateIsolatedAppSupport() throws { diff --git a/TokenStepSwift/Tests/Fixtures/VoyageInterfaceRender.swift b/TokenStepSwift/Tests/Fixtures/VoyageInterfaceRender.swift index cefd423..62ce4bd 100644 --- a/TokenStepSwift/Tests/Fixtures/VoyageInterfaceRender.swift +++ b/TokenStepSwift/Tests/Fixtures/VoyageInterfaceRender.swift @@ -14,9 +14,12 @@ struct VoyageInterfaceRender { let chapter = ProcessInfo.processInfo.environment["TOKENSTEP_ODYSSEY_CHAPTER"] .flatMap(TokenStepOdysseyChapter.init(rawValue:)) ?? .directorsCut - let theme: TokenStepTheme = ProcessInfo.processInfo.environment["TOKENSTEP_THEME_PACK"] == "classic" - ? .green - : .voyage + let theme: TokenStepTheme + switch ProcessInfo.processInfo.environment["TOKENSTEP_THEME_PACK"] { + case "classic": theme = .green + case "interstellar": theme = .eventHorizon + default: theme = .voyage + } let settings = fixtureSettings(theme: theme, chapter: chapter) renderColorScheme = settings.theme.colorScheme try writeJSON(snapshot, to: AppPaths.usageJSON) diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/InterstellarMotionTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/InterstellarMotionTests.swift new file mode 100644 index 0000000..cea12ba --- /dev/null +++ b/TokenStepSwift/Tests/TokenStepSwiftTests/InterstellarMotionTests.swift @@ -0,0 +1,36 @@ +import XCTest +@testable import TokenStepSwift + +final class InterstellarMotionTests: XCTestCase { + func testMotionRunsAtLowCostOnlyOnVisibleInteractiveSurfaces() { + XCTAssertEqual(InterstellarMotionPolicy.preferredFramesPerSecond, 24) + XCTAssertTrue( + InterstellarMotionPolicy.shouldAnimate( + reduceMotion: false, + isScreenshotRendering: false, + isControlActive: true + ) + ) + XCTAssertFalse( + InterstellarMotionPolicy.shouldAnimate( + reduceMotion: true, + isScreenshotRendering: false, + isControlActive: true + ) + ) + XCTAssertFalse( + InterstellarMotionPolicy.shouldAnimate( + reduceMotion: false, + isScreenshotRendering: true, + isControlActive: true + ) + ) + XCTAssertFalse( + InterstellarMotionPolicy.shouldAnimate( + reduceMotion: false, + isScreenshotRendering: false, + isControlActive: false + ) + ) + } +} diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/SettingsMigrationTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/SettingsMigrationTests.swift index 6d0aca6..0becf1f 100644 --- a/TokenStepSwift/Tests/TokenStepSwiftTests/SettingsMigrationTests.swift +++ b/TokenStepSwift/Tests/TokenStepSwiftTests/SettingsMigrationTests.swift @@ -104,6 +104,22 @@ final class SettingsMigrationTests: XCTestCase { XCTAssertEqual(decoded.odysseyChapter, .ashMarble) } + func testInterstellarThemeRoundTripsAsDedicatedThemePack() throws { + var settings = TokenStepSettings.defaults + settings.theme = .eventHorizon + settings.classicTheme = .ocean + + let decoded = try JSONDecoder().decode( + TokenStepSettings.self, + from: JSONEncoder().encode(settings) + ) + + XCTAssertEqual(decoded.theme, .eventHorizon) + XCTAssertEqual(decoded.themePack, .interstellar) + XCTAssertEqual(decoded.classicTheme, .ocean) + XCTAssertEqual(decoded.activeThemeTitle, L("引力边界")) + } + func testCursorFlagStaysInSyncWithProviderSet() { var settings = TokenStepSettings.defaults settings.setQuotaProvider(.cursor, enabled: true) diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/UpdateServiceTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/UpdateServiceTests.swift index 903385c..c209b23 100644 --- a/TokenStepSwift/Tests/TokenStepSwiftTests/UpdateServiceTests.swift +++ b/TokenStepSwift/Tests/TokenStepSwiftTests/UpdateServiceTests.swift @@ -103,13 +103,21 @@ final class UpdateServiceTests: XCTestCase { } } - func testManualCheckBypassesToggleThrottleAndSkippedVersion() { + func testManualPolicyBypassesToggleAndAutomaticThrottleAndSkippedVersion() { let now = Date(timeIntervalSince1970: 100_000) XCTAssertTrue( UpdateCheckPolicy.shouldStart( trigger: .manual, autoUpdateEnabled: false, - lastAttemptAt: now, + lastAttemptAt: now.addingTimeInterval(-10), + now: now + ) + ) + XCTAssertTrue( + UpdateCheckPolicy.shouldStart( + trigger: .manual, + autoUpdateEnabled: false, + lastAttemptAt: now.addingTimeInterval(-9), now: now ) ) @@ -234,13 +242,120 @@ final class UpdateServiceTests: XCTestCase { ) ) gate.finish() - XCTAssertTrue( + XCTAssertFalse( gate.begin( trigger: .manual, autoUpdateEnabled: false, now: now.addingTimeInterval(2) ) ) + XCTAssertTrue( + gate.begin( + trigger: .manual, + autoUpdateEnabled: false, + now: now.addingTimeInterval(10) + ) + ) + } + + func testRecentAutomaticCheckDoesNotBlockManualCheck() { + let now = Date(timeIntervalSince1970: 100_000) + var gate = UpdateCheckGate() + XCTAssertTrue( + gate.begin( + trigger: .startup, + autoUpdateEnabled: true, + now: now + ) + ) + gate.finish() + XCTAssertTrue( + gate.begin( + trigger: .manual, + autoUpdateEnabled: true, + now: now.addingTimeInterval(1) + ) + ) + } + + func testFallbackLatestRedirectBuildsVersionedDMGWithoutGitHubAPI() throws { + let result = try UpdateService.evaluateFallbackReleaseResponse( + responseURL: URL(string: "https://github.com/Backtthefuture/TokenStep/releases/tag/v0.2.7"), + statusCode: 200, + currentVersion: "0.2.6" + ) + + guard case let .available(update) = result else { + return XCTFail("Expected fallback release to be available") + } + XCTAssertEqual(update.version, "0.2.7") + XCTAssertEqual(update.assetName, "TokenStep-0.2.7.dmg") + XCTAssertEqual( + update.assetURL.absoluteString, + "https://github.com/Backtthefuture/TokenStep/releases/download/v0.2.7/TokenStep-0.2.7.dmg" + ) + XCTAssertEqual(update.formattedSize, L("发布包")) + } + + func testFallbackLatestRedirectRejectsUnresolvedLatestPage() { + XCTAssertThrowsError( + try UpdateService.evaluateFallbackReleaseResponse( + responseURL: URL(string: "https://github.com/Backtthefuture/TokenStep/releases/latest"), + statusCode: 200, + currentVersion: "0.2.6" + ) + ) { error in + XCTAssertEqual(error as? UpdateError, .checkFailed) + } + } + + func testRateLimitedAPIUsesNonAPIFallback() async throws { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [UpdateMockURLProtocol.self] + let session = URLSession(configuration: configuration) + UpdateMockURLProtocol.handler = { request in + if request.url?.host == "api.example.com" { + let response = HTTPURLResponse( + url: request.url!, + statusCode: 403, + httpVersion: nil, + headerFields: [ + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": "100000" + ] + )! + return (response, Data()) + } + + let resolvedURL = URL(string: "https://github.com/Backtthefuture/TokenStep/releases/tag/v0.2.7")! + let response = HTTPURLResponse( + url: resolvedURL, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "text/html"] + )! + return (response, Data()) + } + defer { UpdateMockURLProtocol.handler = nil } + + let result = try await UpdateService.checkForUpdates( + currentVersion: "0.2.6", + session: session, + releaseURL: URL(string: "https://api.example.com/releases/latest")!, + fallbackURL: URL(string: "https://www.example.com/releases/latest")! + ) + + guard case let .available(update) = result else { + return XCTFail("Expected fallback update") + } + XCTAssertEqual(update.version, "0.2.7") + } + + func testRateLimitErrorExplainsRecoveryTime() { + let reset = Date(timeIntervalSince1970: 100_000) + let message = UpdateError.rateLimited(resetAt: reset).localizedDescription + XCTAssertTrue(message.contains("GitHub")) + XCTAssertTrue(message.contains(":")) } private func releaseData( @@ -287,3 +402,27 @@ private extension String { hasPrefix("v") ? String(dropFirst()) : self } } + +private final class UpdateMockURLProtocol: URLProtocol { + static var handler: ((URLRequest) throws -> (HTTPURLResponse, Data))? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + guard let handler = Self.handler else { + client?.urlProtocol(self, didFailWithError: UpdateError.checkFailed) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} +} diff --git a/TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonHero.png b/TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonHero.png new file mode 100644 index 0000000..ec44e8c Binary files /dev/null and b/TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonHero.png differ diff --git a/TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonQuiet.png b/TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonQuiet.png new file mode 100644 index 0000000..3361aa1 Binary files /dev/null and b/TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonQuiet.png differ diff --git a/TokenUsageMenuApp/assets/interstellar/README.md b/TokenUsageMenuApp/assets/interstellar/README.md new file mode 100644 index 0000000..4800794 --- /dev/null +++ b/TokenUsageMenuApp/assets/interstellar/README.md @@ -0,0 +1,8 @@ +# Interstellar / Event Horizon artwork + +These two original background plates were generated for TokenStep 0.2.7 from the user-approved “Natural Horizon Close-up” design direction. + +- `InterstellarEventHorizonHero.png`: normal cinematic exposure for the popover, Today dashboard, Token Island, and share cards. +- `InterstellarEventHorizonQuiet.png`: lower-luminance plate for settings, history, privacy, the update window, and Reduce Motion states. + +The production UI adds its own translucent panels, progress rings, accessibility veil, and lightweight accretion-flow animation. No movie artwork, logos, characters, spacecraft, or third-party watermark is bundled. diff --git a/docs/INTERSTELLAR_THEME_PACK_0.2.7.md b/docs/INTERSTELLAR_THEME_PACK_0.2.7.md new file mode 100644 index 0000000..4ef42b7 --- /dev/null +++ b/docs/INTERSTELLAR_THEME_PACK_0.2.7.md @@ -0,0 +1,56 @@ +# TokenStep 0.2.7 “Event Horizon” theme implementation + +## Product identity + +Public theme name: **引力边界 / Event Horizon**. + +“Gargantua” and *Interstellar* were used only as research and design references. The shipping theme uses an original name, original generated artwork, TokenStep typography, and a custom event-horizon mark. + +## Visual system + +- Near-field black-hole shadow extends beyond the upper-left canvas. +- The horizontal accretion disk is wider than 2.4 times the visible shadow diameter. +- A bright ivory gravitational cusp rises near the center. +- The upper lensed arc and thinner inverted lower arc remain visible. +- UI surfaces use graphite glass with stable contrast independent of the image. +- Theme accents are pearl ivory, cream, and restrained champagne; synchronization success is the only persistent green status signal. + +## Production assets + +- `TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonHero.png` +- `TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonQuiet.png` + +The hero plate is used for the popover, Today, Token Island, and share surfaces. The quiet plate is used for denser settings, history, privacy, update, and reduced-motion contexts. + +## Motion + +The image remains the semantic baseline; all data and progress are understandable with motion disabled. A lightweight SwiftUI overlay adds: + +- a 36-second accretion-flow phase drift; +- low-amplitude cusp breathing; +- a secondary thin-band drift; +- a 24 fps ceiling. + +Motion is disabled for deterministic screenshots, macOS Reduce Motion, and inactive window surfaces. + +## Interface coverage + +- Horizontal menu-bar popover and compact/expanded data states +- Today dashboard +- History +- Privacy +- Settings, including the three-pack selector +- Update window and download progress +- Token Island collapsed and expanded states +- Daily and rhythm share cards + +## Theme storage and migration + +- New stored theme value: `event_horizon` +- Theme pack: `interstellar` internally, shown as “引力边界” in the product +- Classic palette and Odyssey chapter remain independently remembered +- Older settings without the new value decode unchanged + +## Copyright boundary + +The release bundles no official poster, film frame, logo, actor likeness, spacecraft, robot, quote, or movie typeface. The black-hole structure follows general scientific visualization features; the final plates are original generated assets. diff --git a/docs/RELEASE_NOTES_0.2.7.md b/docs/RELEASE_NOTES_0.2.7.md new file mode 100644 index 0000000..b9bbbdd --- /dev/null +++ b/docs/RELEASE_NOTES_0.2.7.md @@ -0,0 +1,35 @@ +# TokenStep 0.2.7 + +TokenStep 0.2.7 adds the complete “Event Horizon” black-hole skin pack and makes update checks resilient to GitHub API rate limits. + +## New: Event Horizon skin pack + +- Adds a third theme pack beside Classic and Odyssey. +- Uses an original close-up event-horizon composition with a monumental black-hole shadow, ivory accretion disk, upper gravitational lensing arc, and dimmer inverted lower arc. +- Applies the skin to the popover, Today, History, Privacy, Settings, update window, Token Island, and daily/rhythm share cards. +- Switches the in-app TokenStep mark to an event-horizon/accretion-disk mark while the theme is active. +- Uses restrained graphite, midnight blue, pearl ivory, cream, and muted champagne; green remains reserved for synchronization success. +- Adds a 24 fps maximum ambient layer with slow phase drift and cusp breathing. +- Pauses motion while rendering screenshots, when the window is inactive, and when macOS Reduce Motion is enabled. + +## Update reliability + +- Falls back from the GitHub Releases API to GitHub’s public latest-release redirect when the API is rate-limited or temporarily unavailable. +- Constructs the official versioned DMG URL from the resolved stable release tag. +- Adds a 10-second cooldown to repeated manual checks so rapid clicks cannot consume the anonymous API quota or leave a spinner appearing stuck. +- Shows a specific rate-limit recovery message if both channels fail. +- Keeps the existing signed/notarized DMG verification, replacement, and automatic relaunch flow. + +## Compatibility and privacy + +- Existing Classic and Odyssey choices continue to decode unchanged. +- The Event Horizon choice is stored as a dedicated theme value and survives settings round-trips. +- Token, cost, quota, leaderboard, and local collection semantics are unchanged. +- The bundled artwork is original and contains no movie logo, actor, still, spacecraft, or third-party watermark. + +## Validation + +- Swift unit suite, including theme migration, motion policy, update fallback, cooldown, and update presentation tests. +- Deterministic renders for all major Event Horizon interfaces. +- App bundle resource check for both hero and quiet artwork. +- Signed DMG, Developer ID verification, Apple notarization, and installer verification are release gates. diff --git a/docs/images/tokenstep-0.2.7-event-horizon-popover.jpg b/docs/images/tokenstep-0.2.7-event-horizon-popover.jpg new file mode 100644 index 0000000..f9550fb Binary files /dev/null and b/docs/images/tokenstep-0.2.7-event-horizon-popover.jpg differ diff --git a/script/build_swiftui_and_run.sh b/script/build_swiftui_and_run.sh index 0053e14..8f3143a 100755 --- a/script/build_swiftui_and_run.sh +++ b/script/build_swiftui_and_run.sh @@ -22,7 +22,8 @@ EXECUTABLE="$BUILD_DIR/$PRODUCT_NAME" HELPER_EXECUTABLE="$BUILD_DIR/$HELPER_NAME" ICON_FILE="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" ODYSSEY_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey" -VERSION="${TOKENSTEP_VERSION:-0.2.6}" +INTERSTELLAR_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar" +VERSION="${TOKENSTEP_VERSION:-0.2.7}" LAUNCH=true VERIFY=false @@ -128,6 +129,9 @@ fi if [ -d "$ODYSSEY_ASSET_DIR" ]; then cp "$ODYSSEY_ASSET_DIR"/*.png "$RESOURCES/" fi +if [ -d "$INTERSTELLAR_ASSET_DIR" ]; then + cp "$INTERSTELLAR_ASSET_DIR"/*.png "$RESOURCES/" +fi cat > "$CONTENTS/Info.plist" < diff --git a/script/package_release.sh b/script/package_release.sh index 1b84690..ecd3c96 100755 --- a/script/package_release.sh +++ b/script/package_release.sh @@ -8,14 +8,14 @@ SWIFT_DIR="$ROOT_DIR/TokenStepSwift" BUILT_APP_BUNDLE="$SWIFT_DIR/dist/$APP_NAME.app" APP_BUNDLE="$BUILT_APP_BUNDLE" RELEASE_DIR="$ROOT_DIR/release" -VERSION="${TOKENSTEP_VERSION:-0.2.6}" +VERSION="${TOKENSTEP_VERSION:-0.2.7}" IDENTITY="${CODE_SIGN_IDENTITY:-}" NOTARIZE=false usage() { cat <<'USAGE' Usage: - TOKENSTEP_VERSION=0.2.6 CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./script/package_release.sh [--notarize] + TOKENSTEP_VERSION=0.2.7 CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" ./script/package_release.sh [--notarize] Notarization credentials, choose one: TOKENSTEP_NOTARY_PROFILE="notarytool-profile" diff --git a/script/render_popover_panel.sh b/script/render_popover_panel.sh index fc24e96..71fac90 100755 --- a/script/render_popover_panel.sh +++ b/script/render_popover_panel.sh @@ -8,7 +8,7 @@ OVERLAY_DIR="$BUILD_DIR/vfs-overlay" OVERLAY_FILE="$OVERLAY_DIR/overlay.yaml" EMPTY_MODULEMAP="$OVERLAY_DIR/empty.modulemap" EXECUTABLE="$BUILD_DIR/popover-panel-render" -OUTPUT_DIR="${1:-$ROOT_DIR/docs/validation/v0.2.4-popover-theme-packs}" +OUTPUT_DIR="${1:-$ROOT_DIR/docs/validation/v0.2.7-theme-packs}" mkdir -p "$BUILD_DIR" "$OVERLAY_DIR" "$OUTPUT_DIR" cat > "$EMPTY_MODULEMAP" <<'EOF' @@ -23,13 +23,15 @@ while IFS= read -r source; do SOURCES+=("$source"); done < <(find "$SWIFT_DIR/So swiftc -D TOKENSTEP_TESTING -target arm64-apple-macos14.0 -vfsoverlay "$OVERLAY_FILE" -Xcc -ivfsoverlay -Xcc "$OVERLAY_FILE" -parse-as-library "${SOURCES[@]}" "$SWIFT_DIR/Tests/Fixtures/PopoverPanelRender.swift" -o "$EXECUTABLE" -for theme in green voyage; do - test_root="$(mktemp -d "${TMPDIR:-/tmp}/tokenstep-popover-v024-${theme}.XXXXXX")" +for theme in green voyage event_horizon; do + test_root="$(mktemp -d "${TMPDIR:-/tmp}/tokenstep-popover-v027-${theme}.XXXXXX")" TOKENSTEP_TEST_APP_SUPPORT_ROOT="$test_root/app-support" \ TOKENSTEP_ICON_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" \ TOKENSTEP_ODYSSEY_AEGEAN_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey/OdysseyAegeanPopover.png" \ TOKENSTEP_ODYSSEY_TROJAN_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey/OdysseyTrojanPopover.png" \ TOKENSTEP_ODYSSEY_ASH_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey/OdysseyAshMarblePopover.png" \ + TOKENSTEP_INTERSTELLAR_HERO_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonHero.png" \ + TOKENSTEP_INTERSTELLAR_QUIET_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonQuiet.png" \ TOKENSTEP_ODYSSEY_CHAPTER="${TOKENSTEP_ODYSSEY_CHAPTER:-aegean_mist}" \ TOKENSTEP_POPOVER_THEME="$theme" \ TOKENSTEP_POPOVER_RENDER_PATH="$OUTPUT_DIR/${theme}.png" \ @@ -44,6 +46,8 @@ TOKENSTEP_ICON_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" \ TOKENSTEP_ODYSSEY_AEGEAN_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey/OdysseyAegeanPopover.png" \ TOKENSTEP_ODYSSEY_TROJAN_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey/OdysseyTrojanPopover.png" \ TOKENSTEP_ODYSSEY_ASH_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey/OdysseyAshMarblePopover.png" \ +TOKENSTEP_INTERSTELLAR_HERO_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonHero.png" \ +TOKENSTEP_INTERSTELLAR_QUIET_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonQuiet.png" \ TOKENSTEP_ODYSSEY_CHAPTER="${TOKENSTEP_ODYSSEY_CHAPTER:-aegean_mist}" \ TOKENSTEP_POPOVER_THEME="voyage" \ TOKENSTEP_POPOVER_RANK="hidden" \ diff --git a/script/render_voyage_interfaces.sh b/script/render_voyage_interfaces.sh index d5061d6..2cb9f9b 100755 --- a/script/render_voyage_interfaces.sh +++ b/script/render_voyage_interfaces.sh @@ -8,7 +8,7 @@ OVERLAY_DIR="$BUILD_DIR/vfs-overlay" OVERLAY_FILE="$OVERLAY_DIR/overlay.yaml" EMPTY_MODULEMAP="$OVERLAY_DIR/empty.modulemap" EXECUTABLE="$BUILD_DIR/voyage-interface-render" -OUTPUT_DIR="${1:-$ROOT_DIR/docs/validation/v0.2.4-odyssey-directors-cut}" +OUTPUT_DIR="${1:-$ROOT_DIR/docs/validation/v0.2.7-interstellar}" mkdir -p "$BUILD_DIR" "$OVERLAY_DIR" "$OUTPUT_DIR" cat > "$EMPTY_MODULEMAP" <<'EOF' @@ -23,7 +23,7 @@ while IFS= read -r source; do SOURCES+=("$source"); done < <(find "$SWIFT_DIR/So swiftc -D TOKENSTEP_TESTING -target arm64-apple-macos14.0 -vfsoverlay "$OVERLAY_FILE" -Xcc -ivfsoverlay -Xcc "$OVERLAY_FILE" -parse-as-library "${SOURCES[@]}" "$SWIFT_DIR/Tests/Fixtures/VoyageInterfaceRender.swift" -o "$EXECUTABLE" -test_root="$(mktemp -d "${TMPDIR:-/tmp}/tokenstep-odyssey-v024.XXXXXX")" +test_root="$(mktemp -d "${TMPDIR:-/tmp}/tokenstep-theme-v027.XXXXXX")" trap 'rm -rf "$test_root"' EXIT TOKENSTEP_TEST_APP_SUPPORT_ROOT="$test_root/app-support" \ @@ -31,7 +31,10 @@ TOKENSTEP_ICON_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns" \ TOKENSTEP_ODYSSEY_AEGEAN_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey/OdysseyAegeanPopover.png" \ TOKENSTEP_ODYSSEY_TROJAN_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey/OdysseyTrojanPopover.png" \ TOKENSTEP_ODYSSEY_ASH_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey/OdysseyAshMarblePopover.png" \ +TOKENSTEP_INTERSTELLAR_HERO_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonHero.png" \ +TOKENSTEP_INTERSTELLAR_QUIET_ART_PATH="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar/InterstellarEventHorizonQuiet.png" \ TOKENSTEP_ODYSSEY_CHAPTER="${TOKENSTEP_ODYSSEY_CHAPTER:-directors_cut}" \ +TOKENSTEP_THEME_PACK="${TOKENSTEP_THEME_PACK:-interstellar}" \ TOKENSTEP_VOYAGE_RENDER_DIR="$OUTPUT_DIR" \ "$EXECUTABLE"