diff --git a/README.md b/README.md
index 4b3a115..b525daf 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.8.dmg)
+[下载 TokenStep 最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.9.dmg)
也可以从 Release 页面查看所有版本:
@@ -32,6 +32,26 @@ TokenStep 已使用 Developer ID 签名并通过 Apple 公证。首次打开时
Windows版本由十七做了移植,欢迎大家前往使用:https://github.com/canyexuanfan/TokenStep-Windows/releases
+## 0.2.9 重大更新:引力动效实验室
+
+0.2.9 给「引力边界」主题装上了真正的引力动效。黑洞不再是一张静态壁纸:打开浮层或仪表盘的瞬间,你就在朝它坠落。
+
+
+
+
+
+- **三档签名动效,浮层右上角随时切换**:静谧(只剩环境光呼吸)、轨道(白热等离子光斑拖着彗尾沿吸积盘弧线绕行,星尘坠落)、坠落(黑洞以自身为中心原地膨胀、越来越亮,9 秒内把你吞进去后悬置)。
+- **Token 坠落脉冲**:坠落模式下,今日 Token 增长或点击浮层波形按钮,会从视界方向涌出一圈进食波。
+- **每次打开都是一次新的坠落**:浮层关闭即复位,重新打开从头开始逼近;窗口失焦、被遮挡时动效自动暂停省电。
+- **低成本有底线**:最高 24 fps;低电量模式降到 12 fps 并冻结逼近;截图和 macOS「减少动态效果」下保持完全静态;所有动效由纯函数采样驱动,可用固定时间离线渲染逐帧校验。
+- **可感但克制**:静谧档保持接近静态的呼吸;轨道档持续有可察觉的流动;只有坠落档全力逼近。
+
+
+
+
+
+本次不改变 Token、金额、额度、排行榜及本地采集口径,经典与奥德赛主题不受影响。完整说明见 [0.2.9 发布说明](docs/RELEASE_NOTES_0.2.9.md)。
+
## 0.2.8 更新:引力边界图标精修
0.2.8 精修了「设置 → 通用 → 主题皮肤包」中的引力边界预览图标:黑洞、吸积盘和上下引力透镜弧重新居中并收进图标安全区,同时增加圆角裁切,修复图形穿出卡片边界的问题。
@@ -108,7 +128,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.8.dmg)。
+打开 `设置 → 通用 → 主题皮肤包` 即可切换。完整说明见 [0.2.4 发布说明](docs/RELEASE_NOTES_0.2.4.md),或直接[下载已签名并通过 Apple 公证的最新版](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.9.dmg)。
## TokenStep 适合谁?
@@ -167,7 +187,7 @@ TokenStep 默认只做本地统计。
## 安装方式
-1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.8.dmg)。
+1. 下载 [TokenStep 最新版 DMG](https://github.com/Backtthefuture/TokenStep/releases/latest/download/TokenStep-0.2.9.dmg)。
2. 打开 DMG。
3. 把 `TokenStep.app` 拖到「应用程序」。
4. 启动 TokenStep。
@@ -227,7 +247,7 @@ TokenStepSwift/dist/TokenStep.app
Developer ID 签名:
```bash
-TOKENSTEP_VERSION=0.2.8 \
+TOKENSTEP_VERSION=0.2.9 \
CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
./script/package_release.sh
```
@@ -235,7 +255,7 @@ CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
签名 + Apple 公证:
```bash
-TOKENSTEP_VERSION=0.2.8 \
+TOKENSTEP_VERSION=0.2.9 \
CODE_SIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)" \
TOKENSTEP_NOTARY_PROFILE="tokenstep-notary" \
./script/package_release.sh --notarize
@@ -250,7 +270,7 @@ release/TokenStep-.dmg
维护者说明见 [docs/RELEASE.md](docs/RELEASE.md)。
-0.2.8 发布说明见 [docs/RELEASE_NOTES_0.2.8.md](docs/RELEASE_NOTES_0.2.8.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)。
+0.2.9 发布说明见 [docs/RELEASE_NOTES_0.2.9.md](docs/RELEASE_NOTES_0.2.9.md);0.2.8 发布说明见 [docs/RELEASE_NOTES_0.2.8.md](docs/RELEASE_NOTES_0.2.8.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/Views/InterstellarMotionLab.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/InterstellarMotionLab.swift
new file mode 100644
index 0000000..3603d7d
--- /dev/null
+++ b/TokenStepSwift/Sources/TokenStepSwift/Views/InterstellarMotionLab.swift
@@ -0,0 +1,580 @@
+import Foundation
+import SwiftUI
+
+enum InterstellarMotionMode: String, CaseIterable, Identifiable {
+ case quiet
+ case orbit
+ case gravityTide = "gravity_tide"
+
+ var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .quiet: return "静谧"
+ case .orbit: return "轨道"
+ case .gravityTide: return "坠落"
+ }
+ }
+
+ var preferredFramesPerSecond: Int {
+ switch self {
+ case .quiet: return 12
+ case .orbit, .gravityTide: return 24
+ }
+ }
+
+ var ambientIntensity: Double {
+ switch self {
+ case .quiet: return 0.85
+ case .orbit: return 1.0
+ case .gravityTide: return 1.15
+ }
+ }
+
+ var breathAmplitude: Double {
+ switch self {
+ case .quiet: return 0.05
+ case .orbit: return 0.07
+ case .gravityTide: return 0.09
+ }
+ }
+
+ var ambientBaseOpacity: Double {
+ switch self {
+ case .quiet: return 0.09
+ case .orbit: return 0.115
+ case .gravityTide: return 0.135
+ }
+ }
+
+ var filamentCount: Int {
+ switch self {
+ case .quiet: return 0
+ case .orbit: return 5
+ case .gravityTide: return 6
+ }
+ }
+
+ var starCount: Int {
+ switch self {
+ case .quiet: return 0
+ case .orbit: return 10
+ case .gravityTide: return 16
+ }
+ }
+
+ var showsHotspot: Bool { self != .quiet }
+
+ var usesApproach: Bool { self == .gravityTide }
+}
+
+enum InterstellarMotionLabConfiguration {
+ static let modeKey = "TOKENSTEP_INTERSTELLAR_MOTION_MODE"
+ static let previewTimeKey = "TOKENSTEP_INTERSTELLAR_PREVIEW_TIME"
+ static let previewPulseStartedAtKey = "TOKENSTEP_INTERSTELLAR_PREVIEW_PULSE_START"
+
+ /// Graduated in 0.2.9: the gravity motion lab ships with the Event Horizon
+ /// theme. The remaining environment keys drive deterministic lab rendering.
+ static var isEnabled: Bool { true }
+
+ static var initialMode: InterstellarMotionMode {
+ if let rawValue = ProcessInfo.processInfo.environment[modeKey],
+ let mode = InterstellarMotionMode(rawValue: rawValue) {
+ return mode
+ }
+ return isEnabled ? .gravityTide : .quiet
+ }
+
+ static var previewTime: TimeInterval? {
+ ProcessInfo.processInfo.environment[previewTimeKey].flatMap(TimeInterval.init)
+ }
+
+ static var previewPulseStartedAt: TimeInterval? {
+ ProcessInfo.processInfo.environment[previewPulseStartedAtKey].flatMap(TimeInterval.init)
+ }
+}
+
+enum InterstellarMotionPolicy {
+ static let preferredFramesPerSecond = 24
+ static let lowPowerFramesPerSecond = 12
+
+ static func framesPerSecond(mode: InterstellarMotionMode, lowPowerMode: Bool) -> Int {
+ lowPowerMode
+ ? min(lowPowerFramesPerSecond, mode.preferredFramesPerSecond)
+ : mode.preferredFramesPerSecond
+ }
+
+ static func shouldAnimate(
+ reduceMotion: Bool,
+ isScreenshotRendering: Bool,
+ isSurfaceVisible: Bool
+ ) -> Bool {
+ !reduceMotion && !isScreenshotRendering && isSurfaceVisible
+ }
+}
+
+struct InterstellarMotionSample: Equatable {
+ static let pulseDuration: TimeInterval = 1.4
+ static let approachDuration: TimeInterval = 9
+ static let hotspotPeriod: TimeInterval = 8
+
+ /// Approach travel range over `approachDuration`; free-floating layers ride at 1.7x.
+ /// The arc-glued hotspot scales with the plate so it never detaches from the disk.
+ /// The pivot sits at the heart of the baked structure so the hole swells in
+ /// place — edges rush out of frame while the arc crest climbs toward the top,
+ /// which reads as the camera falling in rather than the plate sliding away.
+ /// A gentle brightness swell rides along: closing in on a blazing disk.
+ static let approachScaleRange: CGFloat = 0.36
+ static let approachEaseExponent: CGFloat = 1.2
+ static let approachBrightnessRange: Double = 0.16
+ static let approachAnchor = UnitPoint(x: 0.46, y: 0.28)
+
+ var primaryPhase: CGFloat
+ var secondaryPhase: CGFloat
+ var starPhase: CGFloat
+ var breath: CGFloat
+ var dollyProgress: CGFloat
+ var hotspotPhase: CGFloat
+ var pulseProgress: CGFloat
+ var pulseStrength: CGFloat
+
+ static func sample(
+ at time: TimeInterval,
+ sessionStartedAt: TimeInterval = 0,
+ pulseStartedAt: TimeInterval? = nil
+ ) -> InterstellarMotionSample {
+ let pulse: (progress: CGFloat, strength: CGFloat)
+ if let pulseStartedAt {
+ let elapsed = time - pulseStartedAt
+ if elapsed >= 0, elapsed <= pulseDuration {
+ let progress = CGFloat(elapsed / pulseDuration)
+ pulse = (progress, sin(.pi * progress))
+ } else {
+ pulse = (elapsed > pulseDuration ? 1 : 0, 0)
+ }
+ } else {
+ pulse = (0, 0)
+ }
+
+ let sessionAge = max(0, time - sessionStartedAt)
+
+ return InterstellarMotionSample(
+ primaryPhase: unitPhase(time, period: 9),
+ secondaryPhase: unitPhase(time, period: 14, offset: 4.5),
+ starPhase: unitPhase(time, period: 11, offset: 7),
+ breath: CGFloat(0.5 + 0.5 * sin(time * 2 * .pi / 8)),
+ dollyProgress: CGFloat(min(1, sessionAge / approachDuration)),
+ hotspotPhase: unitPhase(time, period: hotspotPeriod),
+ pulseProgress: pulse.progress,
+ pulseStrength: pulse.strength
+ )
+ }
+
+ var dollyScale: CGFloat {
+ 1 + Self.approachScaleRange * pow(dollyProgress, Self.approachEaseExponent)
+ }
+
+ /// Brightness swell while closing in on the blazing disk.
+ var dollyBrightness: Double {
+ Self.approachBrightnessRange * pow(dollyProgress, Self.approachEaseExponent)
+ }
+
+ static func hotspotPoint(at phase: CGFloat) -> UnitPoint {
+ let t = Double(wrapped(phase))
+
+ // Cubic bezier fractions hugging the baked-in lensed arc across the
+ // visible top band; the trailing descent along the right edge fades
+ // out before leaving the frame.
+ let start = (x: 0.26, y: -0.05)
+ let control1 = (x: 0.43, y: 0.12)
+ let control2 = (x: 0.70, y: -0.02)
+ let end = (x: 1.05, y: 0.38)
+
+ let inverse = 1 - t
+ let a = inverse * inverse * inverse
+ let b = 3 * inverse * inverse * t
+ let c = 3 * inverse * t * t
+ let d = t * t * t
+
+ return UnitPoint(
+ x: a * start.x + b * control1.x + c * control2.x + d * end.x,
+ y: a * start.y + b * control1.y + c * control2.y + d * end.y
+ )
+ }
+
+ private static func unitPhase(
+ _ time: TimeInterval,
+ period: TimeInterval,
+ offset: TimeInterval = 0
+ ) -> CGFloat {
+ let raw = (time + offset).truncatingRemainder(dividingBy: period)
+ let positive = raw < 0 ? raw + period : raw
+ return CGFloat(positive / period)
+ }
+}
+
+/// Renders one deterministic frame of the motion overlay from a sampled instant.
+struct InterstellarMotionLayer: View {
+ var mode: InterstellarMotionMode
+ var sample: InterstellarMotionSample
+
+ var body: some View {
+ GeometryReader { proxy in
+ ZStack {
+ RadialGradient(
+ colors: [
+ warmWhite.opacity(
+ (mode.ambientBaseOpacity + Double(sample.breath) * mode.breathAmplitude)
+ * mode.ambientIntensity
+ ),
+ Color.clear
+ ],
+ center: UnitPoint(x: 0.44, y: 0.13),
+ startRadius: 2,
+ endRadius: max(proxy.size.width, proxy.size.height) * 0.36
+ )
+ .blendMode(.screen)
+
+ InterstellarDiskFlow(mode: mode, sample: sample)
+
+ if mode.starCount > 0 {
+ InterstellarFallingStars(count: mode.starCount, sample: sample)
+ }
+
+ if mode.usesApproach {
+ InterstellarGravityPulse(sample: sample)
+ }
+ }
+ .frame(width: proxy.size.width, height: proxy.size.height)
+ }
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ }
+
+ private var warmWhite: Color {
+ Color(red: 1.0, green: 0.965, blue: 0.86)
+ }
+}
+
+private struct InterstellarDiskFlow: View {
+ var mode: InterstellarMotionMode
+ var sample: InterstellarMotionSample
+
+ var body: some View {
+ GeometryReader { proxy in
+ ZStack {
+ ForEach(0.. Path {
+ var path = Path()
+ path.move(
+ to: CGPoint(x: rect.width * 0.26, y: rect.height * -0.05)
+ )
+ path.addCurve(
+ to: CGPoint(x: rect.width * 1.05, y: rect.height * 0.38),
+ control1: CGPoint(x: rect.width * 0.43, y: rect.height * 0.12),
+ control2: CGPoint(x: rect.width * 0.70, y: rect.height * -0.02)
+ )
+ return path
+ }
+}
+
+private struct InterstellarFallingStars: View {
+ var count: Int
+ var sample: InterstellarMotionSample
+
+ var body: some View {
+ Canvas { context, size in
+ for index in 0.. CGFloat {
+ let raw = sin(Double(value + 1) * multiplier) * 43_758.5453
+ return CGFloat(raw - floor(raw))
+ }
+}
+
+/// One-shot feeding wavefront that travels out of the event-horizon region;
+/// triggered on open, token growth, or the manual lab button.
+private struct InterstellarGravityPulse: View {
+ var sample: InterstellarMotionSample
+
+ var body: some View {
+ GeometryReader { proxy in
+ let strength = Double(sample.pulseStrength)
+ let progress = sample.pulseProgress
+ let center = CGPoint(x: proxy.size.width * 0.235, y: proxy.size.height * 0.105)
+ ZStack {
+ RadialGradient(
+ colors: [
+ warmWhite.opacity(strength * 0.28),
+ champagne.opacity(strength * 0.10),
+ .clear
+ ],
+ center: UnitPoint(x: 0.235, y: 0.105),
+ startRadius: 1,
+ endRadius: max(proxy.size.width, proxy.size.height) * (0.10 + progress * 0.48)
+ )
+ .blendMode(.screen)
+
+ ForEach(0..<2, id: \.self) { index in
+ let delayed = max(0, min(1, progress - CGFloat(index) * 0.12))
+ Ellipse()
+ .stroke(
+ LinearGradient(
+ colors: [
+ Color.clear,
+ warmWhite.opacity(strength * (index == 0 ? 0.60 : 0.30)),
+ Color.clear
+ ],
+ startPoint: .leading,
+ endPoint: .trailing
+ ),
+ lineWidth: index == 0 ? 1.8 : 0.9
+ )
+ .frame(
+ width: proxy.size.width * (0.18 + delayed * 1.05),
+ height: proxy.size.height * (0.05 + delayed * 0.34)
+ )
+ .position(center)
+ .blur(radius: index == 0 ? 1.4 : 0.8)
+ }
+
+ LinearGradient(
+ colors: [
+ Color.clear,
+ warmWhite.opacity(strength * 0.66),
+ champagne.opacity(strength * 0.24),
+ Color.clear
+ ],
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ .frame(width: proxy.size.width * 0.56, height: 3)
+ .position(x: proxy.size.width * 0.33, y: proxy.size.height * 0.108)
+ .blur(radius: 2)
+ .blendMode(.screen)
+ }
+ .opacity(sample.pulseStrength > 0 ? 1 : 0)
+ }
+ }
+
+ private var warmWhite: Color {
+ Color(red: 1.0, green: 0.965, blue: 0.86)
+ }
+
+ private var champagne: Color {
+ Color(red: 224 / 255, green: 207 / 255, blue: 169 / 255)
+ }
+}
+
+struct InterstellarMotionLabPicker: View {
+ @Binding var mode: InterstellarMotionMode
+ var triggerPulse: () -> Void
+
+ var body: some View {
+ HStack(spacing: 2) {
+ ForEach(InterstellarMotionMode.allCases) { candidate in
+ Button {
+ mode = candidate
+ } label: {
+ Text(candidate.title)
+ .font(.system(size: 9, weight: .heavy, design: .rounded))
+ .foregroundStyle(candidate == mode ? Color.black : Color.tokenInk.opacity(0.66))
+ .frame(width: 34, height: 22)
+ .background(
+ candidate == mode ? Color.tokenGreenDark : Color.clear,
+ in: Capsule()
+ )
+ }
+ .buttonStyle(.plain)
+ .help("引力动效:\(candidate.title)")
+ }
+
+ Button(action: triggerPulse) {
+ Image(systemName: "waveform.path.ecg")
+ .font(.system(size: 9, weight: .black))
+ .foregroundStyle(Color.tokenGreenDark)
+ .frame(width: 24, height: 22)
+ }
+ .buttonStyle(.plain)
+ .disabled(mode != .gravityTide)
+ .opacity(mode == .gravityTide ? 1 : 0.34)
+ .help("触发一次 Token 坠落事件")
+ }
+ .padding(3)
+ .background(Color.black.opacity(0.44), in: Capsule())
+ .overlay(Capsule().stroke(Color.tokenHairlineStrong.opacity(0.70), lineWidth: 1))
+ .accessibilityElement(children: .contain)
+ .accessibilityLabel("引力动效实验室")
+ }
+}
+
+func wrapped(_ value: CGFloat) -> CGFloat {
+ let remainder = value.truncatingRemainder(dividingBy: 1)
+ return remainder < 0 ? remainder + 1 : remainder
+}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/InterstellarSkin.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/InterstellarSkin.swift
index 3470fc8..6c6a2c9 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Views/InterstellarSkin.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Views/InterstellarSkin.swift
@@ -1,18 +1,6 @@
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",
@@ -44,23 +32,113 @@ enum InterstellarArtwork {
struct InterstellarBackdrop: View {
var role: OdysseySurfaceRole = .generic
var isScreenshotRendering = false
+ /// Lab builds drive every cinematic surface with the lab mode; the popover
+ /// passes its own picker state, other surfaces follow the lab default.
+ var motionMode: InterstellarMotionMode = InterstellarMotionLabConfiguration.initialMode
+ var tokenActivity = 0
+ var manualPulseTrigger = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
- @Environment(\.controlActiveState) private var controlActiveState
+ @State private var lowPowerMode = TokenStepPowerState.lowPowerModeEnabled
+ @State private var pulseStartedAt: TimeInterval?
+ @State private var sessionStartedAt = Date.timeIntervalSinceReferenceDate
+ @State private var surfaceVisible = false
private var usesQuietArtwork: Bool {
reduceMotion || [.history, .privacy, .settings, .update].contains(role)
}
+ private var effectiveMode: InterstellarMotionMode {
+ usesQuietArtwork ? .quiet : motionMode
+ }
+
private var motionEnabled: Bool {
InterstellarMotionPolicy.shouldAnimate(
reduceMotion: reduceMotion,
isScreenshotRendering: isScreenshotRendering,
- isControlActive: controlActiveState != .inactive
+ isSurfaceVisible: surfaceVisible
)
}
+ /// Deterministic lab rendering must show the frozen motion frame even
+ /// though the fixture runs in screenshot mode.
+ private var isPreviewRendering: Bool {
+ InterstellarMotionLabConfiguration.previewTime != nil
+ }
+
var body: some View {
+ backdrop
+ .ignoresSafeArea()
+ .allowsHitTesting(false)
+ .accessibilityHidden(true)
+ .background(
+ OdysseySurfaceVisibilityReader(isVisible: $surfaceVisible)
+ )
+ .onAppear {
+ sessionStartedAt = Date.timeIntervalSinceReferenceDate
+ if motionMode == .gravityTide, InterstellarMotionLabConfiguration.isEnabled {
+ triggerPulse()
+ }
+ }
+ .onChange(of: motionMode) { _, newMode in
+ if newMode == .gravityTide {
+ triggerPulse()
+ }
+ }
+ .onChange(of: tokenActivity) { oldValue, newValue in
+ if newValue > oldValue, motionMode == .gravityTide {
+ triggerPulse()
+ }
+ }
+ .onChange(of: manualPulseTrigger) { _, _ in
+ if motionMode == .gravityTide {
+ triggerPulse()
+ }
+ }
+ .onReceive(
+ NotificationCenter.default.publisher(
+ for: Notification.Name.NSProcessInfoPowerStateDidChange
+ )
+ ) { _ in
+ lowPowerMode = TokenStepPowerState.lowPowerModeEnabled
+ }
+ }
+
+ @ViewBuilder
+ private var backdrop: some View {
+ if let previewTime = InterstellarMotionLabConfiguration.previewTime {
+ surface(
+ sample: .sample(
+ at: previewTime,
+ sessionStartedAt: 0,
+ pulseStartedAt: InterstellarMotionLabConfiguration.previewPulseStartedAt
+ ?? pulseStartedAt
+ )
+ )
+ } else {
+ TimelineView(
+ .animation(
+ minimumInterval: 1 / Double(
+ InterstellarMotionPolicy.framesPerSecond(
+ mode: effectiveMode,
+ lowPowerMode: lowPowerMode
+ )
+ ),
+ paused: !motionEnabled
+ )
+ ) { timeline in
+ surface(
+ sample: .sample(
+ at: timeline.date.timeIntervalSinceReferenceDate,
+ sessionStartedAt: sessionStartedAt,
+ pulseStartedAt: pulseStartedAt
+ )
+ )
+ }
+ }
+ }
+
+ private func surface(sample: InterstellarMotionSample) -> some View {
GeometryReader { proxy in
ZStack {
Color(red: 4 / 255, green: 7 / 255, blue: 12 / 255)
@@ -70,16 +148,28 @@ struct InterstellarBackdrop: View {
.resizable()
.interpolation(.high)
.scaledToFill()
+ .scaleEffect(plateScale(for: sample), anchor: InterstellarMotionSample.approachAnchor)
+ .brightness(
+ plateScale(for: sample) == 1 ? 0 : sample.dollyBrightness
+ )
.frame(width: proxy.size.width, height: proxy.size.height)
.clipped()
} else {
InterstellarFallbackPlate()
}
- InterstellarAccretionMotionLayer(isActive: motionEnabled)
-
Color.black.opacity(roleVeilOpacity)
+ // The hotspot is glued to the baked arc, so it rides the plate
+ // scale exactly; free-floating layers below take the parallax.
+ InterstellarHotspotOrbit(mode: effectiveMode, sample: sample)
+ .scaleEffect(plateScale(for: sample), anchor: InterstellarMotionSample.approachAnchor)
+ .opacity(isPreviewRendering || motionEnabled ? 1 : 0)
+
+ InterstellarMotionLayer(mode: effectiveMode, sample: sample)
+ .scaleEffect(overlayScale(for: sample), anchor: InterstellarMotionSample.approachAnchor)
+ .opacity(isPreviewRendering || motionEnabled ? 1 : 0)
+
LinearGradient(
colors: [
Color.black.opacity(0.10),
@@ -98,9 +188,18 @@ struct InterstellarBackdrop: View {
}
.frame(width: proxy.size.width, height: proxy.size.height)
}
- .ignoresSafeArea()
- .allowsHitTesting(false)
- .accessibilityHidden(true)
+ }
+
+ private func plateScale(for sample: InterstellarMotionSample) -> CGFloat {
+ guard effectiveMode.usesApproach, !lowPowerMode else { return 1 }
+ guard isPreviewRendering || !isScreenshotRendering else { return 1 }
+ return sample.dollyScale
+ }
+
+ /// Foreground overlays expand past the plate to sell the parallax depth.
+ private func overlayScale(for sample: InterstellarMotionSample) -> CGFloat {
+ let scale = plateScale(for: sample)
+ return scale == 1 ? 1 : 1 + (scale - 1) * 1.7
}
private var roleVeilOpacity: Double {
@@ -123,69 +222,9 @@ struct InterstellarBackdrop: View {
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 func triggerPulse() {
+ pulseStartedAt = Date.timeIntervalSinceReferenceDate
}
}
diff --git a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift
index c6c2d5e..86f3fda 100644
--- a/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift
+++ b/TokenStepSwift/Sources/TokenStepSwift/Views/PopoverPanelView.swift
@@ -5,6 +5,8 @@ struct PopoverPanelView: View {
@EnvironmentObject private var appState: AppState
@Environment(\.isScreenshotRendering) private var isScreenshotRendering
@State private var odysseyMotionSurfaceVisible = false
+ @State private var interstellarMotionMode = InterstellarMotionLabConfiguration.initialMode
+ @State private var interstellarManualPulseTrigger = 0
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -23,7 +25,13 @@ struct PopoverPanelView: View {
.frame(width: 900)
.background {
if TokenStepThemeRuntime.isInterstellar {
- InterstellarBackdrop(role: .popover, isScreenshotRendering: isScreenshotRendering)
+ InterstellarBackdrop(
+ role: .popover,
+ isScreenshotRendering: isScreenshotRendering,
+ motionMode: interstellarMotionMode,
+ tokenActivity: appState.today.totalTokens,
+ manualPulseTrigger: interstellarManualPulseTrigger
+ )
} else if TokenStepThemeRuntime.isVoyage {
OdysseyPopoverBackdrop(
isMotionSurfaceActive: odysseyMotionSurfaceVisible,
@@ -65,6 +73,15 @@ struct PopoverPanelView: View {
titleSize: TokenStepThemeRuntime.isCinematic ? 20 : 17
)
Spacer()
+ if TokenStepThemeRuntime.isInterstellar,
+ InterstellarMotionLabConfiguration.isEnabled {
+ InterstellarMotionLabPicker(
+ mode: $interstellarMotionMode,
+ triggerPulse: {
+ interstellarManualPulseTrigger &+= 1
+ }
+ )
+ }
HStack(spacing: 6) {
Circle()
.fill(appState.isRefreshing ? Color.secondary.opacity(0.68) : Color.tokenSuccess)
diff --git a/TokenStepSwift/Tests/TokenStepSwiftTests/InterstellarMotionTests.swift b/TokenStepSwift/Tests/TokenStepSwiftTests/InterstellarMotionTests.swift
index cea12ba..babcdf2 100644
--- a/TokenStepSwift/Tests/TokenStepSwiftTests/InterstellarMotionTests.swift
+++ b/TokenStepSwift/Tests/TokenStepSwiftTests/InterstellarMotionTests.swift
@@ -8,29 +8,138 @@ final class InterstellarMotionTests: XCTestCase {
InterstellarMotionPolicy.shouldAnimate(
reduceMotion: false,
isScreenshotRendering: false,
- isControlActive: true
+ isSurfaceVisible: true
)
)
XCTAssertFalse(
InterstellarMotionPolicy.shouldAnimate(
reduceMotion: true,
isScreenshotRendering: false,
- isControlActive: true
+ isSurfaceVisible: true
)
)
XCTAssertFalse(
InterstellarMotionPolicy.shouldAnimate(
reduceMotion: false,
isScreenshotRendering: true,
- isControlActive: true
+ isSurfaceVisible: true
)
)
XCTAssertFalse(
InterstellarMotionPolicy.shouldAnimate(
reduceMotion: false,
isScreenshotRendering: false,
- isControlActive: false
+ isSurfaceVisible: false
)
)
}
+
+ func testMotionModesAndLowPowerFrameCaps() {
+ XCTAssertEqual(InterstellarMotionMode.quiet.preferredFramesPerSecond, 12)
+ XCTAssertEqual(InterstellarMotionMode.orbit.preferredFramesPerSecond, 24)
+ XCTAssertEqual(InterstellarMotionMode.gravityTide.preferredFramesPerSecond, 24)
+ XCTAssertEqual(
+ InterstellarMotionPolicy.framesPerSecond(mode: .gravityTide, lowPowerMode: false),
+ 24
+ )
+ XCTAssertEqual(
+ InterstellarMotionPolicy.framesPerSecond(mode: .gravityTide, lowPowerMode: true),
+ 12
+ )
+ }
+
+ func testMotionSignatureDifferentiatesModes() {
+ XCTAssertFalse(InterstellarMotionMode.quiet.showsHotspot)
+ XCTAssertTrue(InterstellarMotionMode.orbit.showsHotspot)
+ XCTAssertTrue(InterstellarMotionMode.gravityTide.showsHotspot)
+
+ XCTAssertFalse(InterstellarMotionMode.quiet.usesApproach)
+ XCTAssertFalse(InterstellarMotionMode.orbit.usesApproach)
+ XCTAssertTrue(InterstellarMotionMode.gravityTide.usesApproach)
+
+ XCTAssertEqual(InterstellarMotionMode.quiet.filamentCount, 0)
+ XCTAssertEqual(InterstellarMotionMode.quiet.starCount, 0)
+ XCTAssertGreaterThan(InterstellarMotionMode.orbit.filamentCount, 0)
+ XCTAssertGreaterThan(InterstellarMotionMode.gravityTide.starCount, 0)
+ }
+
+ func testMotionSamplePhasesAreBoundedAndPeriodic() {
+ let first = InterstellarMotionSample.sample(at: 12.5)
+ let repeatedPrimary = InterstellarMotionSample.sample(at: 12.5 + 9)
+ let repeatedHotspot = InterstellarMotionSample.sample(
+ at: 12.5 + InterstellarMotionSample.hotspotPeriod
+ )
+
+ for phase in [
+ first.primaryPhase,
+ first.secondaryPhase,
+ first.starPhase,
+ first.breath,
+ first.hotspotPhase
+ ] {
+ XCTAssertGreaterThanOrEqual(phase, 0)
+ XCTAssertLessThanOrEqual(phase, 1)
+ }
+ XCTAssertEqual(first.primaryPhase, repeatedPrimary.primaryPhase, accuracy: 0.000_001)
+ XCTAssertEqual(first.hotspotPhase, repeatedHotspot.hotspotPhase, accuracy: 0.000_001)
+ }
+
+ func testApproachDollyIsMonotonicCappedAndEased() {
+ let start = InterstellarMotionSample.sample(at: 100, sessionStartedAt: 100)
+ XCTAssertEqual(start.dollyProgress, 0, accuracy: 0.000_001)
+ XCTAssertEqual(start.dollyScale, 1, accuracy: 0.000_001)
+
+ let quarter = InterstellarMotionSample.sample(at: 102, sessionStartedAt: 100)
+ let middle = InterstellarMotionSample.sample(at: 105, sessionStartedAt: 100)
+ let done = InterstellarMotionSample.sample(at: 109, sessionStartedAt: 100)
+ let later = InterstellarMotionSample.sample(at: 200, sessionStartedAt: 100)
+
+ XCTAssertGreaterThan(quarter.dollyProgress, start.dollyProgress)
+ XCTAssertGreaterThan(middle.dollyProgress, quarter.dollyProgress)
+ XCTAssertEqual(done.dollyProgress, 1, accuracy: 0.000_001)
+ XCTAssertEqual(
+ done.dollyScale,
+ 1 + InterstellarMotionSample.approachScaleRange,
+ accuracy: 0.000_001
+ )
+ XCTAssertEqual(later.dollyScale, done.dollyScale, accuracy: 0.000_001)
+ XCTAssertEqual(start.dollyBrightness, 0, accuracy: 0.000_001)
+ XCTAssertEqual(
+ done.dollyBrightness,
+ InterstellarMotionSample.approachBrightnessRange,
+ accuracy: 0.000_001
+ )
+
+ // Ease-in: the first quarter of the session travels visibly less than
+ // the linear share of the approach.
+ let linearQuarterGain = InterstellarMotionSample.approachScaleRange * quarter.dollyProgress
+ XCTAssertLessThan(quarter.dollyScale - 1, linearQuarterGain)
+ }
+
+ func testHotspotOrbitIsPeriodicAndHugsTheVisibleArc() {
+ let base = InterstellarMotionSample.sample(at: 3.25)
+ let repeated = InterstellarMotionSample.sample(
+ at: 3.25 + InterstellarMotionSample.hotspotPeriod
+ )
+ XCTAssertEqual(base.hotspotPhase, repeated.hotspotPhase, accuracy: 0.000_001)
+
+ let crest = InterstellarMotionSample.hotspotPoint(at: 0.45)
+ XCTAssertGreaterThan(crest.x, 0.35)
+ XCTAssertLessThan(crest.x, 0.85)
+ XCTAssertGreaterThan(crest.y, -0.08)
+ XCTAssertLessThan(crest.y, 0.35)
+ }
+
+ func testGravityTidePulseIsOneShotAndBounded() {
+ let before = InterstellarMotionSample.sample(at: 9.8, pulseStartedAt: 10)
+ let peak = InterstellarMotionSample.sample(at: 10.7, pulseStartedAt: 10)
+ let after = InterstellarMotionSample.sample(at: 12, pulseStartedAt: 10)
+
+ XCTAssertEqual(before.pulseStrength, 0)
+ XCTAssertEqual(before.pulseProgress, 0)
+ XCTAssertEqual(peak.pulseProgress, 0.5, accuracy: 0.000_001)
+ XCTAssertEqual(peak.pulseStrength, 1, accuracy: 0.000_001)
+ XCTAssertEqual(after.pulseStrength, 0)
+ XCTAssertEqual(after.pulseProgress, 1)
+ }
}
diff --git a/docs/RELEASE_NOTES_0.2.9.md b/docs/RELEASE_NOTES_0.2.9.md
new file mode 100644
index 0000000..dce548f
--- /dev/null
+++ b/docs/RELEASE_NOTES_0.2.9.md
@@ -0,0 +1,34 @@
+# TokenStep 0.2.9
+
+TokenStep 0.2.9 ships the Gravity Motion Lab: the Event Horizon theme stops being a static poster and gains a real sense of gravity. Opening the popover or the dashboard now drops you toward the black hole.
+
+## Three signature motion modes
+
+A compact selector lives in the popover header next to the sync badge:
+
+- **Quiet**: only a slow ambient breathing over the plate — for people who want the theme essentially still.
+- **Orbit**: a white-hot plasma knot with a comet tail orbits along the baked-in lensed arc every 8 seconds, while disk-flow filaments and falling stars ride the visible top band.
+- **Plunge** (default): the black hole swells in place around its own structural center — edges rush out of frame, the whole scene brightens by up to 16%, and after 9 seconds the approach holds at its closest point. Closing the popover resets the dive; every open is a fresh approach.
+
+## Token plunge pulse
+
+In Plunge mode, a one-shot feeding wavefront radiates from the event-horizon region when today's token count grows or when the waveform button in the lab selector is pressed.
+
+## Motion stays honest and cheap
+
+- 24 fps ceiling; low-power mode drops to 12 fps and freezes the approach while the hotspot keeps orbiting.
+- Fully static for screenshot rendering and macOS Reduce Motion; motion pauses automatically when a window is occluded or closed (surface-visibility gating, not focus state).
+- Every frame is sampled from a pure function (`InterstellarMotionSample`), so the render script can produce deterministic frames at fixed timestamps and the validation harness measures frame-to-frame perceptibility: the full plunge changes 64% of the frame beyond an 8/255 delta, with 27% within the first 3 seconds.
+- The arc-following hotspot shares the plate's transform exactly, so it never detaches from the disk during the approach.
+
+## Compatibility and scope
+
+- Classic and Odyssey theme packs are unchanged.
+- Token, cost, quota, leaderboard, updater, and local collection behavior are unchanged.
+- The motion lab graduates from its experimental gate: it ships enabled with the Event Horizon theme, and the selector is part of the popover UI.
+
+## Validation
+
+- Swift unit suite: 113 tests, 0 failures, including approach monotonicity/cap, hotspot periodicity and arc attachment, pulse one-shot bounds, and low-power frame caps.
+- Deterministic motion-lab renders at 1800 × 1118 for quiet/orbit/plunge phases, archived with frame-diff metrics under `docs/validation/v0.2.9-motion-lab/`.
+- Developer ID signing, Apple notarization, stapling, DMG validation, and isolated installer verification remain release gates.
diff --git a/docs/images/tokenstep-0.2.9-motion-lab-orbit.gif b/docs/images/tokenstep-0.2.9-motion-lab-orbit.gif
new file mode 100644
index 0000000..f937a63
Binary files /dev/null and b/docs/images/tokenstep-0.2.9-motion-lab-orbit.gif differ
diff --git a/docs/images/tokenstep-0.2.9-motion-lab-plunge.gif b/docs/images/tokenstep-0.2.9-motion-lab-plunge.gif
new file mode 100644
index 0000000..514e4eb
Binary files /dev/null and b/docs/images/tokenstep-0.2.9-motion-lab-plunge.gif differ
diff --git a/script/build_swiftui_and_run.sh b/script/build_swiftui_and_run.sh
index e1d6665..9656927 100755
--- a/script/build_swiftui_and_run.sh
+++ b/script/build_swiftui_and_run.sh
@@ -23,7 +23,7 @@ HELPER_EXECUTABLE="$BUILD_DIR/$HELPER_NAME"
ICON_FILE="$ROOT_DIR/TokenUsageMenuApp/assets/TokenStepIcon.icns"
ODYSSEY_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/odyssey"
INTERSTELLAR_ASSET_DIR="$ROOT_DIR/TokenUsageMenuApp/assets/interstellar"
-VERSION="${TOKENSTEP_VERSION:-0.2.8}"
+VERSION="${TOKENSTEP_VERSION:-0.2.9}"
LAUNCH=true
VERIFY=false
diff --git a/script/render_interstellar_motion_lab.sh b/script/render_interstellar_motion_lab.sh
new file mode 100755
index 0000000..6e8b3ca
--- /dev/null
+++ b/script/render_interstellar_motion_lab.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SWIFT_DIR="$ROOT_DIR/TokenStepSwift"
+BUILD_DIR="$SWIFT_DIR/.build/interstellar-motion-lab-render"
+OVERLAY_DIR="$BUILD_DIR/vfs-overlay"
+OVERLAY_FILE="$OVERLAY_DIR/overlay.yaml"
+EMPTY_MODULEMAP="$OVERLAY_DIR/empty.modulemap"
+EXECUTABLE="$BUILD_DIR/interstellar-motion-lab-render"
+OUTPUT_DIR="${1:-$ROOT_DIR/docs/validation/v0.2.9-motion-lab}"
+
+mkdir -p "$BUILD_DIR" "$OVERLAY_DIR" "$OUTPUT_DIR"
+cat > "$EMPTY_MODULEMAP" <<'EOF'
+// Intentionally empty.
+EOF
+cat > "$OVERLAY_FILE" <