@@ -614,8 +614,19 @@ private struct RobotMascot: View {
614614 }
615615 }
616616
617+ // Three-tier interval keeps the heavy 10fps cadence for actively
618+ // changing visuals while still leaving headroom for the idle micro-
619+ // loops (head tilt every ~12s, ear flick every ~10s, blink every ~9s,
620+ // ghost bob) — those run off TimelineView ticks and would freeze
621+ // entirely at a 60s interval. 0.5s = 2fps is plenty for the slow
622+ // idle accents and still ~25× cheaper than the original 0.05s.
623+ // Two-tier interval. The 0.5s idle cadence is paired with widened blink /
624+ // ear-flick / micro-loop windows below so each animation is reliably
625+ // sampled at 2fps. Going lower (e.g. 60s) freezes the idle micro-loops
626+ // we added in the mascot-reactions release; going faster wastes cycles
627+ // when nothing visibly changing depends on a higher framerate.
617628 private var timelineInterval : TimeInterval {
618- ( state == . busy || hovered || pulse > 0 ) ? 0.1 : 60
629+ ( state == . busy || hovered || pulse > 0 ) ? 0.1 : 0.5
619630 }
620631
621632 // Deterministic seed so multi-monitor pills don't sync. Offsets the
@@ -745,7 +756,10 @@ private struct RobotMascot: View {
745756 private func eyes( at t: TimeInterval ) -> some View {
746757 // Blink: scale the eyes vertically toward 0 every ~3.2s for ~150ms.
747758 let cycle = t. truncatingRemainder ( dividingBy: 3.2 )
748- let blinking = state != . alert && cycle < 0.15
759+ // 0.5s window matches the idle TimelineView cadence so each blink
760+ // is guaranteed to be sampled at least once. The wider window
761+ // reads as a sleepy slow blink, fitting the idle state.
762+ let blinking = state != . alert && cycle < 0.5
749763 let scaleY : CGFloat = blinking ? 0.15 : 1.0
750764
751765 return HStack ( spacing: 4.5 ) {
@@ -885,16 +899,30 @@ private struct CatMascot: View {
885899 }
886900 }
887901
902+ // Three-tier interval keeps the heavy 10fps cadence for actively
903+ // changing visuals while still leaving headroom for the idle micro-
904+ // loops (head tilt every ~12s, ear flick every ~10s, blink every ~9s,
905+ // ghost bob) — those run off TimelineView ticks and would freeze
906+ // entirely at a 60s interval. 0.5s = 2fps is plenty for the slow
907+ // idle accents and still ~25× cheaper than the original 0.05s.
908+ // Two-tier interval. The 0.5s idle cadence is paired with widened blink /
909+ // ear-flick / micro-loop windows below so each animation is reliably
910+ // sampled at 2fps. Going lower (e.g. 60s) freezes the idle micro-loops
911+ // we added in the mascot-reactions release; going faster wastes cycles
912+ // when nothing visibly changing depends on a higher framerate.
888913 private var timelineInterval : TimeInterval {
889- ( state == . busy || hovered || pulse > 0 ) ? 0.1 : 60
914+ ( state == . busy || hovered || pulse > 0 ) ? 0.1 : 0.5
890915 }
891916
892917 // Tiny vertical bump every ~10s — reads as "ear flick".
893918 private static let idleSeed : Double = 2.7 // offset from robot's phase
894919 private func idleEarFlick( at t: TimeInterval ) -> CGFloat {
895920 let phase = ( t + Self. idleSeed) . truncatingRemainder ( dividingBy: 10.0 )
896- guard phase > 9.4 else { return 0 }
897- return CGFloat ( - sin( ( phase - 9.4 ) / 0.6 * . pi) ) * 0.8
921+ // Active arc widened from 0.6s → 1.5s so a 0.5s idle tick lands at
922+ // least 1–2 samples inside it. Reads as a slower, more deliberate
923+ // ear-flick which suits the resting state.
924+ guard phase > 8.5 else { return 0 }
925+ return CGFloat ( - sin( ( phase - 8.5 ) / 1.5 * . pi) ) * 0.8
898926 }
899927
900928 @ViewBuilder
@@ -995,7 +1023,10 @@ private struct CatMascot: View {
9951023
9961024 private func eyes( at t: TimeInterval ) -> some View {
9971025 let cycle = t. truncatingRemainder ( dividingBy: 3.5 )
998- let blinking = state != . alert && cycle < 0.15
1026+ // 0.5s window matches the idle TimelineView cadence so each blink
1027+ // is guaranteed to be sampled at least once. The wider window
1028+ // reads as a sleepy slow blink, fitting the idle state.
1029+ let blinking = state != . alert && cycle < 0.5
9991030 let scaleY : CGFloat = blinking ? 0.15 : 1.0
10001031 // While hovered, scale the left eye flat → looks like a wink.
10011032 let leftScale : CGFloat = hovered ? 0.15 : scaleY
@@ -1186,17 +1217,31 @@ private struct EyeMascot: View {
11861217 }
11871218 }
11881219
1220+ // Three-tier interval keeps the heavy 10fps cadence for actively
1221+ // changing visuals while still leaving headroom for the idle micro-
1222+ // loops (head tilt every ~12s, ear flick every ~10s, blink every ~9s,
1223+ // ghost bob) — those run off TimelineView ticks and would freeze
1224+ // entirely at a 60s interval. 0.5s = 2fps is plenty for the slow
1225+ // idle accents and still ~25× cheaper than the original 0.05s.
1226+ // Two-tier interval. The 0.5s idle cadence is paired with widened blink /
1227+ // ear-flick / micro-loop windows below so each animation is reliably
1228+ // sampled at 2fps. Going lower (e.g. 60s) freezes the idle micro-loops
1229+ // we added in the mascot-reactions release; going faster wastes cycles
1230+ // when nothing visibly changing depends on a higher framerate.
11891231 private var timelineInterval : TimeInterval {
1190- ( state == . busy || hovered || pulse > 0 ) ? 0.1 : 60
1232+ ( state == . busy || hovered || pulse > 0 ) ? 0.1 : 0.5
11911233 }
11921234
11931235 // Squint-and-open every ~9s. Returns vertical scale that dips toward
11941236 // 0.4 briefly so the lens looks like it's closing for a moment.
11951237 private static let idleSeed : Double = 5.1
11961238 private func idleBlinkScale( at t: TimeInterval ) -> CGFloat {
11971239 let phase = ( t + Self. idleSeed) . truncatingRemainder ( dividingBy: 9.0 )
1198- guard phase > 8.7 else { return 1 }
1199- let local = ( phase - 8.7 ) / 0.3 // 0…1 over 300ms
1240+ // Active window widened from 0.3s → 1.0s so a 0.5s idle tick lands
1241+ // at least one sample inside the squint. Reads as a longer, more
1242+ // pronounced lens-narrow when idle.
1243+ guard phase > 8.0 else { return 1 }
1244+ let local = ( phase - 8.0 ) / 1.0
12001245 return CGFloat ( 1.0 - 0.6 * sin( local * . pi) )
12011246 }
12021247
@@ -1380,8 +1425,19 @@ private struct GhostMascot: View {
13801425 }
13811426 }
13821427
1428+ // Three-tier interval keeps the heavy 10fps cadence for actively
1429+ // changing visuals while still leaving headroom for the idle micro-
1430+ // loops (head tilt every ~12s, ear flick every ~10s, blink every ~9s,
1431+ // ghost bob) — those run off TimelineView ticks and would freeze
1432+ // entirely at a 60s interval. 0.5s = 2fps is plenty for the slow
1433+ // idle accents and still ~25× cheaper than the original 0.05s.
1434+ // Two-tier interval. The 0.5s idle cadence is paired with widened blink /
1435+ // ear-flick / micro-loop windows below so each animation is reliably
1436+ // sampled at 2fps. Going lower (e.g. 60s) freezes the idle micro-loops
1437+ // we added in the mascot-reactions release; going faster wastes cycles
1438+ // when nothing visibly changing depends on a higher framerate.
13831439 private var timelineInterval : TimeInterval {
1384- ( state == . busy || hovered || pulse > 0 ) ? 0.1 : 60
1440+ ( state == . busy || hovered || pulse > 0 ) ? 0.1 : 0.5
13851441 }
13861442
13871443 private var trailingSparkle : some View {
@@ -1457,7 +1513,10 @@ private struct GhostMascot: View {
14571513
14581514 private func eyes( at t: TimeInterval ) -> some View {
14591515 let cycle = t. truncatingRemainder ( dividingBy: 3.0 )
1460- let blinking = state != . alert && cycle < 0.15
1516+ // 0.5s window matches the idle TimelineView cadence so each blink
1517+ // is guaranteed to be sampled at least once. The wider window
1518+ // reads as a sleepy slow blink, fitting the idle state.
1519+ let blinking = state != . alert && cycle < 0.5
14611520 let scaleY : CGFloat = blinking ? 0.15 : 1.0
14621521 return HStack ( spacing: 4 ) { eyeShape; eyeShape }
14631522 . scaleEffect ( x: 1 , y: scaleY)
0 commit comments