Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2b04534
test: reduce motion stops every loop; static status indicators; 2 Hz …
teamleaderleo Oct 14, 2026
cd50c6c
Reduce Motion stills Home's caret and typing dots, the update and imp…
teamleaderleo Oct 14, 2026
f4fe4c0
Status indicators: Animated or Static; 2 Hz blink cap
teamleaderleo Oct 14, 2026
13323e9
settings: export appearance.statusIndicator.animation
teamleaderleo Oct 14, 2026
e601ce6
Merge origin/feat-cmux-next (55c4131f5884) into feat/reduce-motion-st…
teamleaderleo Oct 14, 2026
708d014
MessagesLab: the Reduce Motion pin behind a lock (crash ratchet: no u…
teamleaderleo Oct 14, 2026
17c38a4
Static status indicators: the pane attention ring is the steady ring
teamleaderleo Oct 14, 2026
68a1e5c
Static status indicators: no visible label on sidebar rows
teamleaderleo Oct 14, 2026
9d43e88
Merge commit '7b0207929c8f212608cd153eebf85db2ea5cc35e' into feat/red…
teamleaderleo Oct 14, 2026
dcf6982
Settings exports: regenerate on the merged head
teamleaderleo Oct 14, 2026
f98263d
test: static status reaches the web pane; no web loop under static or…
teamleaderleo Oct 14, 2026
1fb2d2d
test: the web red tests typecheck before the kit exists
teamleaderleo Oct 14, 2026
8b4c880
Static status reaches the web pane; shared late-content motion kit
teamleaderleo Oct 14, 2026
114f0ca
Motion+Reveal: public import QuartzCore for its public API
teamleaderleo Oct 14, 2026
395e046
test: the shape check resolves the pane's theme colors, as in the app
teamleaderleo Oct 14, 2026
4d6c7a6
Merge feat-cmux-next into feat/reduce-motion-status
teamleaderleo Oct 14, 2026
fd5c2b0
fix: rebuild status catalogs as base text plus the new keys
teamleaderleo Oct 14, 2026
c98fa57
chore: regenerate settings exports on the merged head
teamleaderleo Oct 14, 2026
c6d04a7
chore(next): PR autofix at c98fa5777d27
Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
--- a/Sources/MessagesLabSidebar/Vendor/appkit-native/SidebarDrawing.swift
+++ b/Sources/MessagesLabSidebar/Vendor/appkit-native/SidebarDrawing.swift
@@ -648,7 +648,7 @@
for d in lit { d.backgroundColor = p.typingDot; d.contentsScale = scale }
}
/// The dots pulse (tests: every lit dot has its animation, or the still phase is set).
- var isPulsing: Bool { Self.stillPhase != nil || lit.allSatisfy { $0.animation(forKey: "pulse") != nil } }
+ var isPulsing: Bool { stillPhase != nil || lit.allSatisfy { $0.animation(forKey: "pulse") != nil } }
/// Still scenes: the dots stand at this phase of the pulse (0-1) instead of animating (nil: animate).
static var stillPhase: Double?
private static func level(_ i: Int, phase t: Double) -> Double {
@@ -658,8 +658,10 @@
}
/// Starts the pulse once. Phase-locked to whole periods of the media clock, so every bubble
/// in the list pulses in step and a row that scrolls back in does not restart its cycle.
+ /// cmux: the still phase, also the first dot's peak under Reduce Motion (cx-sya4n: nothing loops).
+ private var stillPhase: Double? { Self.stillPhase ?? (CmuxSidebarView.reducesMotion ? 0.45 : nil) }
func animate() {
- if let ph = Self.stillPhase {
+ if let ph = stillPhase {
for (i, d) in lit.enumerated() { d.opacity = Float(Self.level(i, phase: ph)) }
return
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,25 @@
/// Marked (IME) text changed. UITextView reports marked text through
/// `textViewDidChange`, so the draft (and the field height) follows the
/// composition; NSTextView does not post a text change for it.
@@ -417,6 +420,7 @@
@@ -180,7 +183,8 @@
if Self.caretLog { Self.log("blink restart \(CACurrentMediaTime())") }
caretLayer.removeAnimation(forKey: "blink")
caretLayer.opacity = 1
- if Self.solidCaret { return }
+ // cmux: under Reduce Motion the caret holds solid (cx-sya4n: nothing loops).
+ if Self.solidCaret || MessagesLabHomeView.reducesMotion { return }
let a = CAKeyframeAnimation(keyPath: "opacity")
a.calculationMode = .discrete
// One period from the first fade-out: out 1 -> 0 in four steps, off, in 0 -> 1.
@@ -417,6 +421,7 @@

override func paste(_ sender: Any?) {
let pb = pasteboard
+ if onPastePasteboard(pb) { return } // cmux
if pb.string(forType: .string) == nil, let img = NSImage(pasteboard: pb) { onPasteImage(img); return }
super.paste(sender)
}
@@ -920,11 +924,11 @@
@@ -920,11 +925,11 @@
fmt.opaque = false
let ph = placeholder
placeholderLayer.contents = UIGraphicsImageRenderer(size: CGSize(width: 300, height: 30), format: fmt).image { ctx in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,32 @@
/// Window y of the cell's top: the outgoing fill shades with it.
var windowY: CGFloat = 0 {
didSet {
@@ -1397,6 +1406,8 @@
a.isRemovedOnCompletion = false
a.calculationMode = .linear
if let p = Springs.typingStillPhase { a.speed = 0; a.timeOffset = p }
+ // cmux: under Reduce Motion the dots stand at the first dot's peak, played once (cx-sya4n: nothing loops).
+ else if MessagesLabHomeView.reducesMotion { a.speed = 0; a.timeOffset = Self.typingDotPeak; a.repeatCount = 1 }
hi.add(a, forKey: "dots")
}
}
@@ -1406,7 +1417,8 @@
/// (layer time). A still scene (--typing-phase) keeps them at rest. macOS only (no iOS reference).
func startTypingBreath(insert: CFTimeInterval) {
#if !os(iOS) || targetEnvironment(macCatalyst)
- guard Springs.typingStillPhase == nil else { return }
+ // cmux: no breathing under Reduce Motion (cx-sya4n).
+ guard Springs.typingStillPhase == nil, !MessagesLabHomeView.reducesMotion else { return }
let a = RowDraw.typingAnchor, b = Springs.typingBreathAnchor
// The container scales about RowDraw.typingAnchor; the opposite offset of a scale e about it moves the
// fixed point to b: e x (a - b).
@@ -1505,7 +1517,8 @@
/// The typing row should breathe but does not (macOS, not a still scene): its breathing was stopped or never started.
var needsTypingBreath: Bool {
#if !os(iOS) || targetEnvironment(macCatalyst)
- Springs.typingStillPhase == nil && typingContainer.animation(forKey: "breath.transform.scale") == nil
+ // cmux: no breathing under Reduce Motion (cx-sya4n).
+ Springs.typingStillPhase == nil && !MessagesLabHomeView.reducesMotion && typingContainer.animation(forKey: "breath.transform.scale") == nil
#else
false
#endif
1 change: 1 addition & 0 deletions Packages/Shared/CmuxMessagesLab/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ and render-server field animation, blurred header and native scrolling.
| RowDrawing | file, audio, contact and voice memo rows on my side use the theme's sent text colour (white by default; a light accent showed white text) |
| Engine, WindowView | `cmuxSetAttachment`: an attachment part's picture or upload state changed in HomeStore (no content change, no transition; the row redraws in place) |
| Engine, Layout, Transcript | `cmuxNotice`: the host's notice (Home's merge notice) is MessagesLab's centered system row under the newest message, not an overlay; its accessibility label has no leading space |
| Compose, Transcript, SidebarDrawing (sidebar) | Reduce Motion (cx-sya4n: nothing loops): the caret holds solid, the typing dots stand at one phase and the typing bubble does not breathe (`MessagesLabHomeView.reduceMotionOverride` / `CmuxSidebarView.reduceMotionOverride`: cmux-next mirrors its `Motion.reduceMotionOverride`; nil reads the system setting) |
| Compose | `onPastePasteboard`: the field's paste reaches the host's attachment intake first (Home's type rule, prepared by HomeStore) |
| Layout | `code` runs draw monospaced with one font held for the process (`HomeFonts.code`: a font made per run on the row render threads came back nil and crashed) |
| LinkPreviews | the cache lives in the app's own caches folder (`<bundle id>/link-previews`), not MessagesLab's |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import AppKit
import os

/// cmux (cx-sya4n: Austin gets headaches from blinking lights; nothing loops under
/// Reduce Motion): the vendored transcript's caret and typing row read this.
extension MessagesLabHomeView {
/// The host's Reduce Motion pin (cmux-next mirrors `Motion.reduceMotionOverride`, the
/// tests' pin); nil reads the system setting.
nonisolated public static var reduceMotionOverride: Bool? {
get { reduceMotionPin.withLock { $0 } }
set { reduceMotionPin.withLock { $0 = newValue } }
}
nonisolated private static let reduceMotionPin = OSAllocatedUnfairLock<Bool?>(initialState: nil)

/// Reduce Motion: the caret holds solid, the typing dots stand still and do not breathe.
nonisolated static var reducesMotion: Bool {
reduceMotionOverride ?? NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,8 @@ final class FieldTextView: NSTextView {
if Self.caretLog { Self.log("blink restart \(CACurrentMediaTime())") }
caretLayer.removeAnimation(forKey: "blink")
caretLayer.opacity = 1
if Self.solidCaret { return }
// cmux: under Reduce Motion the caret holds solid (cx-sya4n: nothing loops).
if Self.solidCaret || MessagesLabHomeView.reducesMotion { return }
let a = CAKeyframeAnimation(keyPath: "opacity")
a.calculationMode = .discrete
// One period from the first fade-out: out 1 -> 0 in four steps, off, in 0 -> 1.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1462,6 +1462,8 @@ final class RowCell: UICollectionViewCell {
a.isRemovedOnCompletion = false
a.calculationMode = .linear
if let p = Springs.typingStillPhase { a.speed = 0; a.timeOffset = p }
// cmux: under Reduce Motion the dots stand at the first dot's peak, played once (cx-sya4n: nothing loops).
else if MessagesLabHomeView.reducesMotion { a.speed = 0; a.timeOffset = Self.typingDotPeak; a.repeatCount = 1 }
hi.add(a, forKey: "dots")
}
}
Expand All @@ -1471,7 +1473,8 @@ final class RowCell: UICollectionViewCell {
/// (layer time). A still scene (--typing-phase) keeps them at rest. macOS only (no iOS reference).
func startTypingBreath(insert: CFTimeInterval) {
#if !os(iOS) || targetEnvironment(macCatalyst)
guard Springs.typingStillPhase == nil else { return }
// cmux: no breathing under Reduce Motion (cx-sya4n).
guard Springs.typingStillPhase == nil, !MessagesLabHomeView.reducesMotion else { return }
let a = RowDraw.typingAnchor, b = Springs.typingBreathAnchor
// The container scales about RowDraw.typingAnchor; the opposite offset of a scale e about it moves the
// fixed point to b: e x (a - b).
Expand Down Expand Up @@ -1570,7 +1573,8 @@ final class RowCell: UICollectionViewCell {
/// The typing row should breathe but does not (macOS, not a still scene): its breathing was stopped or never started.
var needsTypingBreath: Bool {
#if !os(iOS) || targetEnvironment(macCatalyst)
Springs.typingStillPhase == nil && typingContainer.animation(forKey: "breath.transform.scale") == nil
// cmux: no breathing under Reduce Motion (cx-sya4n).
Springs.typingStillPhase == nil && !MessagesLabHomeView.reducesMotion && typingContainer.animation(forKey: "breath.transform.scale") == nil
#else
false
#endif
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import AppKit
import os

/// cmux (cx-sya4n: Austin gets headaches from blinking lights; nothing loops under
/// Reduce Motion): the vendored list's typing bubble reads this.
extension CmuxSidebarView {
/// The host's Reduce Motion pin (cmux-next mirrors `Motion.reduceMotionOverride`, the
/// tests' pin); nil reads the system setting.
nonisolated public static var reduceMotionOverride: Bool? {
get { reduceMotionPin.withLock { $0 } }
set { reduceMotionPin.withLock { $0 = newValue } }
}
nonisolated private static let reduceMotionPin = OSAllocatedUnfairLock<Bool?>(initialState: nil)

/// Reduce Motion: the typing bubble's dots stand still.
nonisolated static var reducesMotion: Bool {
reduceMotionOverride ?? NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,7 @@ final class SidebarTypingLayer: CALayer {
for d in lit { d.backgroundColor = p.typingDot; d.contentsScale = scale }
}
/// The dots pulse (tests: every lit dot has its animation, or the still phase is set).
var isPulsing: Bool { Self.stillPhase != nil || lit.allSatisfy { $0.animation(forKey: "pulse") != nil } }
var isPulsing: Bool { stillPhase != nil || lit.allSatisfy { $0.animation(forKey: "pulse") != nil } }
/// Still scenes: the dots stand at this phase of the pulse (0-1) instead of animating (nil: animate).
static var stillPhase: Double?
private static func level(_ i: Int, phase t: Double) -> Double {
Expand All @@ -658,8 +658,10 @@ final class SidebarTypingLayer: CALayer {
}
/// Starts the pulse once. Phase-locked to whole periods of the media clock, so every bubble
/// in the list pulses in step and a row that scrolls back in does not restart its cycle.
/// cmux: the still phase, also the first dot's peak under Reduce Motion (cx-sya4n: nothing loops).
private var stillPhase: Double? { Self.stillPhase ?? (CmuxSidebarView.reducesMotion ? 0.45 : nil) }
func animate() {
if let ph = Self.stillPhase {
if let ph = stillPhase {
for (i, d) in lit.enumerated() { d.opacity = Float(Self.level(i, phase: ph)) }
return
}
Expand Down
3 changes: 3 additions & 0 deletions Packages/macOS/CmuxNext/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,9 @@ let package = Package(
dependencies: ["CmuxNextWakeups", "CmuxNextNowPlaying", "CmuxNextLayout", "CmuxNextTasks", "CmuxNextAgentCursor", "CmuxNextTabs", "CmuxNextProcessEnvironment", "CmuxNextApp", "CmuxNextBrowserHistory", "CmuxNextCrashReporting", "CmuxNextActions", "CmuxNextHistory", "CmuxNextCopyMode",
"CmuxNextComputerUse", "CmuxNextDaemon", "CmuxNextDaemonService", "CmuxNextFocusUI", "CmuxNextHome", "CmuxNextHomeEngine", .product(name: "CmuxHomeCore", package: "CmuxHomeCore"),
.product(name: "CmuxHomeRender", package: "CmuxHomeRender"),
// ReduceMotionLoopTests builds the vendored Home typing indicators.
.product(name: "MessagesLabHome", package: "CmuxMessagesLab"),
.product(name: "MessagesLabSidebar", package: "CmuxMessagesLab"),
.product(name: "CmuxAgentQuestion", package: "CmuxAgentQuestion"),
// GhosttyGlassMinimumContrastTests builds a Ghostty config.
.product(name: "CmuxGhosttyKit", package: "CmuxGhosttyKit")],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ enum AgentPaneTheme {
"fadeOut": motion.duration(MotionFade.fadeOut),
"move": motion.duration(MotionSpring.move),
],
// Static status indicators, or Reduce Motion (cx-sya4n): the page stops its status
// loops and draws a shape per state (`data-status-indicators`).
"statusIndicators": motion.reduceMotion || DesignSettings.shared.statusIndicator.animation == .static ? "static" : "animated",
]
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ public final class AgentPaneView: NSView {

private func observeMotion() {
motionObservation = Task { [weak self] in
for await _ in ObservationStream({ Motion.speed }) {
for await _ in ObservationStream({ (Motion.speed, DesignSettings.shared.statusIndicator.animation) }) {
guard let self else { return }
self.applyTheme()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,11 +343,13 @@ extension Motion {
}

/// Attention flash: two blinks, one fade under Reduce Motion, nil when off.
/// The blinks peak half a `flash` period apart (the 2 Hz cap,
/// `MotionLoop.shortestFlashCycle`).
public static func flashAnimation() -> CAAnimation? {
guard animatesFades else { return nil }
let flash = CAKeyframeAnimation(keyPath: "opacity")
if animatesLoops {
flash.values = [0, 1, 1, 0, 1, 0]
flash.values = [0, 1, 0, 1, 0]
flash.duration = MotionLoop.flash.period * speed.timeScale
} else {
flash.values = [1, 0]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ extension Motion {
public static func attentionAnimation(_ settings: AttentionSettings,
look: AttentionHighlightLook = AttentionHighlightLook.tunable.value) -> CAAnimation? {
guard animatesFades, settings.style != .none else { return nil }
// Static status indicators (cx-sya4n): the steady ring, no blink or pulse.
var settings = settings
if DesignSettings.shared.statusIndicator.animation == .static { settings.style = .steady }
let rest = attentionRestingOpacity(settings, look: look)
let peak = look.peakOpacity
guard animatesLoops else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
public import QuartzCore

/// Late content settles in, it does not pop (cx-sya4n; plans/cmux-next/motion.md "Late
/// content"). Motion is small and local (Leo 2026-10-14): a short fade with a few points of
/// rise, the first few rows of a list a step apart, never a whole pane travelling. The web
/// side is webviews/src/ui/motion.ts with the same numbers.
extension Motion {
/// Items of a list that get their own stagger step; later ones arrive with the last.
public static let staggerItems = 4
/// Seconds between two staggered items.
public static let staggerStep: TimeInterval = 0.025
/// Points revealed content rises as it settles.
public static let revealRise: CGFloat = 3

/// The delay of item `index` of a list that arrives at once: a short step for the first
/// few items, none past them. 0 under Reduce Motion and with speed "off".
// TODO(#20467): also 0 during a live resize once `Motion.isLiveResizing` lands.
public static func stagger(index: Int) -> TimeInterval {
guard policy.animatesMovement, index > 0 else { return 0 }
return Double(min(index, staggerItems - 1)) * staggerStep
}

/// Fades `layer` in from clear while it rises `revealRise` points into place on the `appear`
/// spring, after `stagger(index:)`; under Reduce Motion it only fades, at once. Returns the
/// added animation, or nil when fades are off. The layer's model values are unchanged.
@discardableResult
public static func reveal(_ layer: CALayer, index: Int = 0) -> CAAnimation? {
let policy = policy
let fade = policy.duration(MotionFade.fadeIn)
guard fade > 0 else { return nil }
let opacity = CABasicAnimation(keyPath: "opacity")
opacity.fromValue = 0
opacity.toValue = layer.opacity
opacity.duration = fade
opacity.timingFunction = fadeCurve
var animation: CAAnimation = opacity
if policy.animatesMovement {
// Below its place: down is negative y unless the layer is flipped.
let below = layer.contentsAreFlipped() ? revealRise : -revealRise
let rise = springAnimation(.appear, "transform.translation.y", from: below, to: 0)
rise.duration = policy.spring(.appear).perceivedDuration
let group = CAAnimationGroup()
group.animations = [opacity, rise]
group.duration = max(opacity.duration, rise.duration)
animation = group
}
let delay = stagger(index: index)
if delay > 0 {
animation.beginTime = layer.convertTime(CACurrentMediaTime(), from: nil) + delay
animation.fillMode = .backwards
}
layer.add(animation, forKey: "cmux.reveal")
return animation
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,5 +45,22 @@ public nonisolated enum MotionLoop: String, Sendable, CaseIterable {
/// Pane attention flash (two blinks).
case flash

public var period: TimeInterval { MotionTunables.loops[self]?.value ?? 1 }
/// Seconds per period, never under `minimumPeriod` (a Debug Settings
/// override included).
public var period: TimeInterval { max(MotionTunables.loops[self]?.value ?? 1, minimumPeriod) }

/// The 2 Hz cap (cx-sya4n: Austin gets headaches from blinking lights):
/// nothing flashes faster than twice a second, so every on-off-on cycle
/// of an opacity loop lasts at least `shortestFlashCycle`. A pulse is
/// one cycle per period, a flash two blinks per period; a spinner turns
/// and does not flash.
public static let shortestFlashCycle: TimeInterval = 0.5

public var minimumPeriod: TimeInterval {
switch self {
case .spinner: 0.1
case .pulse: Self.shortestFlashCycle
case .flash: Self.shortestFlashCycle * 2
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,14 @@ public nonisolated enum MotionTunables {
static let loopDefaults: [MotionLoop: (TimeInterval, String)] = [
.spinner: (0.9, "One turn of the busy spinner."),
.pulse: (1.8, "One cycle of the agent-waiting pulse."),
.flash: (0.6, "Pane attention flash (two blinks)."),
.flash: (1, "Pane attention flash (two blinks; at least 1 s, the 2 Hz cap)."),
]

static let loops: [MotionLoop: Tunable<Double>] = Dictionary(uniqueKeysWithValues: MotionLoop.allCases.map { loop in
let entry = loopDefaults[loop] ?? (1, "")
return (loop, Tunable.number("motion.loop.\(loop.rawValue)", .fades, "\(loop.rawValue) period", help: entry.1,
default: entry.0, range: 0.1...4, step: 0.05, unit: .seconds, code: "MotionTunables.loopDefaults[.\(loop.rawValue)]"))
default: entry.0, range: loop.minimumPeriod...4, step: 0.05, unit: .seconds,
code: "MotionTunables.loopDefaults[.\(loop.rawValue)]"))
})

// MARK: Marquee and panel scale
Expand Down
Loading
Loading