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 给「引力边界」主题装上了真正的引力动效。黑洞不再是一张静态壁纸:打开浮层或仪表盘的瞬间,你就在朝它坠落。 + +

+ TokenStep 0.2.9 坠落模式:黑洞向你压来 +

+ +- **三档签名动效,浮层右上角随时切换**:静谧(只剩环境光呼吸)、轨道(白热等离子光斑拖着彗尾沿吸积盘弧线绕行,星尘坠落)、坠落(黑洞以自身为中心原地膨胀、越来越亮,9 秒内把你吞进去后悬置)。 +- **Token 坠落脉冲**:坠落模式下,今日 Token 增长或点击浮层波形按钮,会从视界方向涌出一圈进食波。 +- **每次打开都是一次新的坠落**:浮层关闭即复位,重新打开从头开始逼近;窗口失焦、被遮挡时动效自动暂停省电。 +- **低成本有底线**:最高 24 fps;低电量模式降到 12 fps 并冻结逼近;截图和 macOS「减少动态效果」下保持完全静态;所有动效由纯函数采样驱动,可用固定时间离线渲染逐帧校验。 +- **可感但克制**:静谧档保持接近静态的呼吸;轨道档持续有可察觉的流动;只有坠落档全力逼近。 + +

+ TokenStep 0.2.9 轨道模式:等离子光斑绕行 +

+ +本次不改变 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" <