From a40e43e46eaeaae05884d1209b31194d30b52f42 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 12 Jul 2026 06:35:02 +0100 Subject: [PATCH] feat(mac): redesign the menu bar critter to match the mascot (#104846) * feat(mac): redesign menu bar critter to match the mascot * fix(mac): draw happy-eye arcs upward and guard stale celebration expiry * docs(mac): update menu bar icon states for critter redesign * fix(mac): own celebration expiry with a start-only generation counter * fix(mac): give voice-wake antennae real headroom at 18pt --- .../OpenClaw/CritterIconRenderer.swift | 334 +++++++++--------- .../CritterStatusLabel+Behavior.swift | 35 +- .../Sources/OpenClaw/CritterStatusLabel.swift | 2 + .../CritterIconRendererTests.swift | 13 +- docs/platforms/mac/icon.md | 11 +- 5 files changed, 217 insertions(+), 178 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/CritterIconRenderer.swift b/apps/macos/Sources/OpenClaw/CritterIconRenderer.swift index 701b28e6936..2f1b1845043 100644 --- a/apps/macos/Sources/OpenClaw/CritterIconRenderer.swift +++ b/apps/macos/Sources/OpenClaw/CritterIconRenderer.swift @@ -18,100 +18,102 @@ enum CritterIconRenderer { let context: CGContext } + private struct Antenna { + let start: CGPoint + let control: CGPoint + let tip: CGPoint + } + private struct Geometry { let bodyRect: CGRect - let leftArmRect: CGRect - let rightArmRect: CGRect - let leftEarRect: CGRect - let rightEarRect: CGRect + let armRadius: CGFloat + let leftArmCenter: CGPoint + let rightArmCenter: CGPoint let antennaLineWidth: CGFloat + let leftAntenna: Antenna + let rightAntenna: Antenna let legW: CGFloat let legH: CGFloat - let legSpacing: CGFloat - let legStartX: CGFloat + let legCenters: [CGFloat] let legYBase: CGFloat let legLift: CGFloat - let legHeightScale: CGFloat let eyeSize: CGSize let eyeY: CGFloat let eyeOffset: CGFloat - init(canvas: Canvas, legWiggle: CGFloat, earWiggle: CGFloat, earScale: CGFloat) { + init(canvas: Canvas, legWiggle: CGFloat, earWiggle: CGFloat, earScale: CGFloat, antennaDroop: CGFloat) { let w = canvas.w let h = canvas.h - let snapX = canvas.snapX - let snapY = canvas.snapY + let cx = w / 2 - let bodyW = snapX(w * 0.68) - let bodyH = snapY(h * 0.68) - let bodyX = snapX((w - bodyW) / 2) - let bodyY = snapY(h * 0.24) + // Chubby near-circular body: the mascot's dominant shape must survive 18pt. + let bodyW = w * 0.72 + let bodyH = h * 0.66 + let bodyX = (w - bodyW) / 2 + let bodyY = h * 0.19 + let bodyTop = bodyY + bodyH - let armSize = snapX(w * 0.2) - let armY = snapY(bodyY + bodyH * 0.36) - let leftArmRect = CGRect( - x: snapX(bodyX - armSize * 0.62), - y: armY, - width: armSize, - height: armSize) - let rightArmRect = CGRect( - x: snapX(bodyX + bodyW - armSize * 0.38), - y: armY, - width: armSize, - height: armSize) + // Round arm nubs poking out at the sides, slightly below center like the mascot. + let armRadius = w * 0.10 + let armY = bodyY + bodyH * 0.42 + self.armRadius = armRadius + self.leftArmCenter = CGPoint(x: bodyX + armRadius * 0.1, y: armY) + self.rightArmCenter = CGPoint(x: bodyX + bodyW - armRadius * 0.1, y: armY) - let antennaW = snapX(w * 0.22) - let antennaH = snapY(min(bodyH * 0.24 * earScale, h * 0.19)) - let antennaLineWidth = max(snapX(w * 0.095), canvas.stepX * 2) * min(1.2, 0.94 + earScale * 0.06) - let antennaLift = snapY(earWiggle * 0.35) - let leftEarRect = CGRect( - x: snapX(bodyX + bodyW * 0.18 - antennaW * 0.35 - earWiggle * 0.28), - y: snapY(bodyY + bodyH * 0.86 + antennaLift), - width: antennaW, - height: antennaH) - let rightEarRect = CGRect( - x: snapX(bodyX + bodyW * 0.82 - antennaW * 0.65 + earWiggle * 0.28), - y: snapY(bodyY + bodyH * 0.86 - antennaLift), - width: antennaW, - height: antennaH) + // Stubby antennae curling outward; droop folds them down when asleep/paused. + // Voice-wake earScale straightens and lengthens them instead of growing horns. + let antennaLineWidth = w * 0.115 + let reach = 1 + (min(earScale, 1.9) - 1) * 0.6 + let wiggleShift = earWiggle * 0.28 - let legW = snapX(w * 0.15) - let legH = snapY(h * 0.25) - let legSpacing = snapX(w * 0.16) - let legsWidth = snapX(2 * legW + legSpacing) - let legStartX = snapX((w - legsWidth) / 2) - let legLift = snapY(legH * 0.35 * legWiggle) - let legYBase = snapY(bodyY - legH * 0.58) - let legHeightScale = 1 - 0.12 * legWiggle - - let eyeSize = CGSize( - width: snapX(bodyW * 0.15), - height: snapY(bodyH * 0.2)) - let eyeY = snapY(bodyY + bodyH * 0.58) - let eyeOffset = snapX(bodyW * 0.22) - - self.bodyRect = CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH) - self.leftArmRect = leftArmRect - self.rightArmRect = rightArmRect - self.leftEarRect = leftEarRect - self.rightEarRect = rightEarRect + func antenna(side: CGFloat) -> Antenna { + let start = CGPoint(x: cx + side * bodyW * 0.16, y: bodyTop - antennaLineWidth * 0.9) + // Idle tips sit below the canvas clamp so the voice-wake reach has + // real headroom; boosted antennae go taller AND steeper (x pulls + // inward) or the perk would be invisible at 18pt. + let upTip = CGPoint( + x: cx + side * bodyW * (0.40 - 0.20 * (reach - 1)) + side * wiggleShift, + y: min(bodyTop + h * 0.06 * reach + earWiggle * 0.2 * side, h - antennaLineWidth * 0.55)) + let upControl = CGPoint( + x: cx + side * bodyW * 0.22, + y: bodyTop + h * 0.075 * reach) + let downTip = CGPoint(x: cx + side * bodyW * 0.52, y: bodyTop - h * 0.13) + let downControl = CGPoint(x: cx + side * bodyW * 0.38, y: bodyTop + h * 0.04) + let tip = CGPoint( + x: upTip.x + (downTip.x - upTip.x) * antennaDroop, + y: upTip.y + (downTip.y - upTip.y) * antennaDroop) + let control = CGPoint( + x: upControl.x + (downControl.x - upControl.x) * antennaDroop, + y: upControl.y + (downControl.y - upControl.y) * antennaDroop) + return Antenna(start: start, control: control, tip: tip) + } + self.leftAntenna = antenna(side: -1) + self.rightAntenna = antenna(side: 1) self.antennaLineWidth = antennaLineWidth + + // Stubby legs tucked under the body; they must overlap the body so the + // silhouette stays connected at menu bar size. + let legW = w * 0.14 + let legH = h * 0.18 self.legW = legW self.legH = legH - self.legSpacing = legSpacing - self.legStartX = legStartX - self.legYBase = legYBase - self.legLift = legLift - self.legHeightScale = legHeightScale - self.eyeSize = eyeSize - self.eyeY = eyeY - self.eyeOffset = eyeOffset + self.legCenters = [cx - w * 0.13, cx + w * 0.13] + self.legYBase = bodyY - legH * 0.5 + self.legLift = legH * 0.35 * legWiggle + + // Big friendly eyes just above center. + self.eyeSize = CGSize(width: bodyW * 0.22, height: bodyH * 0.26) + self.eyeY = bodyY + bodyH * 0.58 + self.eyeOffset = bodyW * 0.22 + + self.bodyRect = CGRect(x: bodyX, y: bodyY, width: bodyW, height: bodyH) } } private struct FaceOptions { let blink: CGFloat let eyesClosedLines: Bool + let happyEyes: Bool } static func makeIcon( @@ -119,8 +121,9 @@ enum CritterIconRenderer { legWiggle: CGFloat = 0, earWiggle: CGFloat = 0, earScale: CGFloat = 1, - earHoles: Bool = false, + antennaDroop: CGFloat = 0, eyesClosedLines: Bool = false, + happyEyes: Bool = false, badge: Badge? = nil) -> NSImage { guard let rep = self.makeBitmapRep() else { @@ -143,12 +146,14 @@ enum CritterIconRenderer { canvas: canvas, legWiggle: legWiggle, earWiggle: earWiggle, - earScale: earHoles ? max(earScale, 1.2) : earScale) + earScale: earScale, + antennaDroop: antennaDroop) self.drawBody(in: canvas, geometry: geometry) let face = FaceOptions( blink: blink, - eyesClosedLines: eyesClosedLines) + eyesClosedLines: eyesClosedLines, + happyEyes: happyEyes) self.drawFace(in: canvas, geometry: geometry, options: face) if let badge { @@ -199,59 +204,48 @@ enum CritterIconRenderer { } private static func drawBody(in canvas: Canvas, geometry: Geometry) { - canvas.context.setStrokeColor(NSColor.labelColor.cgColor) - canvas.context.setLineWidth(geometry.antennaLineWidth) - canvas.context.setLineCap(.round) - canvas.context.setLineJoin(.round) + let ctx = canvas.context - let leftStart = CGPoint( - x: canvas.snapX(geometry.bodyRect.minX + geometry.bodyRect.width * 0.34), - y: canvas.snapY(geometry.bodyRect.maxY - geometry.antennaLineWidth * 0.22)) - let leftEnd = CGPoint( - x: canvas.snapX(geometry.leftEarRect.minX), - y: canvas.snapY(geometry.leftEarRect.maxY)) - let leftControl = CGPoint( - x: canvas.snapX(geometry.leftEarRect.midX), - y: canvas.snapY(geometry.leftEarRect.minY)) - let rightStart = CGPoint( - x: canvas.snapX(geometry.bodyRect.maxX - geometry.bodyRect.width * 0.34), - y: canvas.snapY(geometry.bodyRect.maxY - geometry.antennaLineWidth * 0.22)) - let rightEnd = CGPoint( - x: canvas.snapX(geometry.rightEarRect.maxX), - y: canvas.snapY(geometry.rightEarRect.maxY)) - let rightControl = CGPoint( - x: canvas.snapX(geometry.rightEarRect.midX), - y: canvas.snapY(geometry.rightEarRect.minY)) + // Antennae first so the body fill covers their roots. + ctx.setStrokeColor(NSColor.labelColor.cgColor) + ctx.setLineWidth(geometry.antennaLineWidth) + ctx.setLineCap(.round) + ctx.setLineJoin(.round) let antennae = CGMutablePath() - antennae.move(to: leftStart) - antennae.addQuadCurve(to: leftEnd, control: leftControl) - antennae.move(to: rightStart) - antennae.addQuadCurve(to: rightEnd, control: rightControl) - canvas.context.addPath(antennae) - canvas.context.strokePath() + for antenna in [geometry.leftAntenna, geometry.rightAntenna] { + antennae.move(to: antenna.start) + antennae.addQuadCurve(to: antenna.tip, control: antenna.control) + } + ctx.addPath(antennae) + ctx.strokePath() - canvas.context.setFillColor(NSColor.labelColor.cgColor) + ctx.setFillColor(NSColor.labelColor.cgColor) - for i in 0..<2 { - let x = geometry.legStartX + CGFloat(i) * (geometry.legW + geometry.legSpacing) + for (i, legCenter) in geometry.legCenters.enumerated() { let lift = i % 2 == 0 ? geometry.legLift : -geometry.legLift let rect = CGRect( - x: x, + x: legCenter - geometry.legW / 2, y: geometry.legYBase + lift, width: geometry.legW, - height: geometry.legH * geometry.legHeightScale) - canvas.context.addPath(CGPath( + height: geometry.legH) + ctx.addPath(CGPath( roundedRect: rect, - cornerWidth: geometry.legW * 0.34, - cornerHeight: geometry.legW * 0.34, + cornerWidth: geometry.legW / 2, + cornerHeight: geometry.legW / 2, transform: nil)) } - canvas.context.addEllipse(in: geometry.leftArmRect) - canvas.context.addEllipse(in: geometry.rightArmRect) - canvas.context.addEllipse(in: geometry.bodyRect) - canvas.context.fillPath() + for center in [geometry.leftArmCenter, geometry.rightArmCenter] { + ctx.addEllipse(in: CGRect( + x: center.x - geometry.armRadius, + y: center.y - geometry.armRadius, + width: geometry.armRadius * 2, + height: geometry.armRadius * 2)) + } + + ctx.addEllipse(in: geometry.bodyRect) + ctx.fillPath() } private static func drawFace( @@ -259,60 +253,67 @@ enum CritterIconRenderer { geometry: Geometry, options: FaceOptions) { - canvas.context.saveGState() - canvas.context.setBlendMode(.clear) + let ctx = canvas.context + let leftCenter = CGPoint(x: canvas.w / 2 - geometry.eyeOffset, y: geometry.eyeY) + let rightCenter = CGPoint(x: canvas.w / 2 + geometry.eyeOffset, y: geometry.eyeY) - let leftCenter = CGPoint( - x: canvas.snapX(canvas.w / 2 - geometry.eyeOffset), - y: canvas.snapY(geometry.eyeY)) - let rightCenter = CGPoint( - x: canvas.snapX(canvas.w / 2 + geometry.eyeOffset), - y: canvas.snapY(geometry.eyeY)) + ctx.saveGState() + ctx.setBlendMode(.clear) - if options.eyesClosedLines { - let lineW = canvas.snapX(geometry.eyeSize.width * 1.15) - let lineH = canvas.snapY(max(canvas.stepY * 2, geometry.bodyRect.height * 0.06)) - let corner = canvas.snapX(lineH * 0.6) - let leftRect = CGRect( - x: canvas.snapX(leftCenter.x - lineW / 2), - y: canvas.snapY(leftCenter.y - lineH / 2), - width: lineW, - height: lineH) - let rightRect = CGRect( - x: canvas.snapX(rightCenter.x - lineW / 2), - y: canvas.snapY(rightCenter.y - lineH / 2), - width: lineW, - height: lineH) - canvas.context.addPath(CGPath( - roundedRect: leftRect, - cornerWidth: corner, - cornerHeight: corner, - transform: nil)) - canvas.context.addPath(CGPath( - roundedRect: rightRect, - cornerWidth: corner, - cornerHeight: corner, - transform: nil)) - } else { - let eyeOpen = max(0.05, 1 - options.blink) - let eyeH = canvas.snapY(geometry.eyeSize.height * eyeOpen) - let leftRect = CGRect( - x: canvas.snapX(leftCenter.x - geometry.eyeSize.width / 2), - y: canvas.snapY(leftCenter.y - eyeH / 2), - width: geometry.eyeSize.width, - height: eyeH) - let rightRect = CGRect( - x: canvas.snapX(rightCenter.x - geometry.eyeSize.width / 2), - y: canvas.snapY(rightCenter.y - eyeH / 2), - width: geometry.eyeSize.width, - height: eyeH) - - canvas.context.addEllipse(in: leftRect) - canvas.context.addEllipse(in: rightRect) + if options.happyEyes || options.eyesClosedLines { + // Curved lids: happy "∩ ∩" for celebrations, sleepy "⌣ ⌣" while dozing. + let radius = geometry.eyeSize.width * 0.62 + let lineWidth = max(canvas.stepY * 2, geometry.eyeSize.height * 0.34) + ctx.setLineWidth(lineWidth) + ctx.setLineCap(.round) + for center in [leftCenter, rightCenter] { + let arcCenter = CGPoint( + x: center.x, + y: center.y + (options.happyEyes ? -radius * 0.4 : radius * 0.55)) + let path = CGMutablePath() + // Counterclockwise sweeps the short arc in this y-up context: + // top half for "∩", bottom half for "⌣". + path.addArc( + center: arcCenter, + radius: radius, + startAngle: options.happyEyes ? .pi * 0.12 : .pi * 1.12, + endAngle: options.happyEyes ? .pi * 0.88 : .pi * 1.88, + clockwise: false) + ctx.addPath(path) + } + ctx.replacePathWithStrokedPath() + ctx.fillPath() + ctx.restoreGState() + return } - canvas.context.fillPath() - canvas.context.restoreGState() + // Blink squeezes the eye toward a soft line so the face never vanishes mid-blink. + let eyeOpen = max(0.22, 1 - options.blink) + let eyeH = geometry.eyeSize.height * eyeOpen + for center in [leftCenter, rightCenter] { + ctx.addEllipse(in: CGRect( + x: center.x - geometry.eyeSize.width / 2, + y: center.y - eyeH / 2, + width: geometry.eyeSize.width, + height: eyeH)) + } + ctx.fillPath() + ctx.restoreGState() + + // Glossy glint inside each open eye, echoing the mascot's shiny pupils. + // Skipped while blinking: a dot inside a near-closed lid reads as noise. + if eyeH > geometry.eyeSize.height * 0.7 { + let glintR = geometry.eyeSize.width * 0.26 + ctx.setFillColor(NSColor.labelColor.cgColor) + for center in [leftCenter, rightCenter] { + ctx.addEllipse(in: CGRect( + x: center.x - geometry.eyeSize.width * 0.22 - glintR, + y: center.y + geometry.eyeSize.height * 0.18 - glintR, + width: glintR * 2, + height: glintR * 2)) + } + ctx.fillPath() + } } private static func drawBadge(_ badge: Badge, canvas: Canvas) { @@ -322,12 +323,11 @@ enum CritterIconRenderer { case .overridden: 0.85 } - // Bigger, higher-contrast badge: - // - Increase diameter so tool activity is noticeable. - // - Draw a filled "puck", then knock out the symbol shape (transparent hole). - // This reads better in template-rendered menu bar icons than tiny monochrome glyphs. - let diameter = canvas.snapX(canvas.w * 0.52 * (0.92 + 0.08 * strength)) // ~9–10pt on an 18pt canvas - let margin = canvas.snapX(max(0.45, canvas.w * 0.03)) + // Filled "puck" with the symbol knocked out (transparent hole): reads better + // in template-rendered menu bar icons than tiny monochrome glyphs. Sized so + // the critter behind it stays recognizable. + let diameter = canvas.snapX(canvas.w * 0.46 * (0.92 + 0.08 * strength)) + let margin = canvas.snapX(max(0.45, canvas.w * 0.02)) let rect = CGRect( x: canvas.snapX(canvas.w - diameter - margin), y: canvas.snapY(margin), diff --git a/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift b/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift index ddc11b3b465..08c12899043 100644 --- a/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift +++ b/apps/macos/Sources/OpenClaw/CritterStatusLabel+Behavior.swift @@ -41,7 +41,7 @@ extension CritterStatusLabel { } .onChange(of: self.sendCelebrationTick) { _, _ in guard self.effectiveAnimationsEnabled, !self.earBoostActive else { return } - self.wiggleLegs() + self.celebrate() } .onChange(of: self.animationsEnabled) { _, enabled in if enabled, !self.isSleeping { @@ -145,11 +145,17 @@ extension CritterStatusLabel { } if self.isPaused { - return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, badge: nil)) + // Paused reads as "off duty": awake but with drooped antennae, distinct + // from idle (perked) and sleeping (drooped + closed eyes). + return Image(nsImage: CritterIconRenderer.makeIcon(blink: 0, antennaDroop: 1, badge: nil)) } if self.isSleeping { - return Image(nsImage: CritterIconRenderer.makeIcon(blink: 1, eyesClosedLines: true, badge: nil)) + return Image(nsImage: CritterIconRenderer.makeIcon( + blink: 1, + antennaDroop: 1, + eyesClosedLines: true, + badge: nil)) } return Image(nsImage: CritterIconRenderer.makeIcon( @@ -157,18 +163,35 @@ extension CritterStatusLabel { legWiggle: max(self.legWiggle, self.isWorkingNow ? 0.6 : 0), earWiggle: self.earWiggle, earScale: self.earBoostActive ? 1.9 : 1.0, - earHoles: self.earBoostActive, + happyEyes: self.celebrating, badge: badge)) } private func resetMotion() { self.blinkAmount = 0 + self.celebrating = false self.wiggleAngle = 0 self.wiggleOffset = 0 self.legWiggle = 0 self.earWiggle = 0 } + /// Message sent: flash happy "∩ ∩" eyes and kick the legs. + private func celebrate() { + self.celebrating = true + self.wiggleLegs() + // Generation advances only for celebrations that actually start, so the + // newest flash always owns the clear: older expiry tasks bail, and the + // eyes can never stick on after a skipped send tick. + self.celebrationGeneration += 1 + let generation = self.celebrationGeneration + Task { @MainActor in + try? await Task.sleep(nanoseconds: 900_000_000) + guard self.celebrationGeneration == generation else { return } + self.celebrating = false + } + } + private func blink() { withAnimation(.easeInOut(duration: 0.08)) { self.blinkAmount = 1 } Task { @MainActor in @@ -290,6 +313,7 @@ extension CritterStatusLabel { label.wiggleLegs() label.wiggleEars() label.scurry() + label.celebrate() label.scheduleRandomTimers(from: Date()) _ = label.gatewayNeedsAttention _ = label.gatewayBadgeColor @@ -338,8 +362,9 @@ extension CritterStatusLabel { legWiggle: 0.8, earWiggle: 0.4, earScale: 1.4, - earHoles: true, + antennaDroop: 0.5, eyesClosedLines: true, + happyEyes: true, badge: .init(symbolName: "gearshape.fill", prominence: .secondary)) } } diff --git a/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift b/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift index 024015937d3..2742e32dc73 100644 --- a/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift +++ b/apps/macos/Sources/OpenClaw/CritterStatusLabel.swift @@ -13,6 +13,8 @@ struct CritterStatusLabel: View { var voiceWakeMeterActive: Bool = false @State var blinkAmount: CGFloat = 0 + @State var celebrating = false + @State var celebrationGeneration = 0 @State var nextBlink = Date().addingTimeInterval(Double.random(in: 3.5...8.5)) @State var wiggleAngle: Double = 0 @State var wiggleOffset: CGFloat = 0 diff --git a/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift b/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift index 1384ab6320e..e19aab9202b 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CritterIconRendererTests.swift @@ -10,7 +10,6 @@ struct CritterIconRendererTests { legWiggle: 0.5, earWiggle: 0.2, earScale: 1, - earHoles: true, badge: nil) #expect(image.size.width == 18) @@ -24,12 +23,22 @@ struct CritterIconRendererTests { legWiggle: 0, earWiggle: 0, earScale: 1, - earHoles: false, badge: .init(symbolName: "terminal.fill", prominence: .primary)) #expect(image.tiffRepresentation != nil) } + @Test func `make icon renders expressive states`() { + let sleeping = CritterIconRenderer.makeIcon( + blink: 1, + antennaDroop: 1, + eyesClosedLines: true) + let celebrating = CritterIconRenderer.makeIcon(blink: 0, happyEyes: true) + + #expect(sleeping.tiffRepresentation != nil) + #expect(celebrating.tiffRepresentation != nil) + } + @Test func `critter status label exercises helpers`() async { await CritterStatusLabel.exerciseForTesting() } diff --git a/docs/platforms/mac/icon.md b/docs/platforms/mac/icon.md index 315c3ef6749..26b5a4df850 100644 --- a/docs/platforms/mac/icon.md +++ b/docs/platforms/mac/icon.md @@ -13,9 +13,11 @@ Scope: macOS app (`apps/macos`). Rendering: `CritterIconRenderer.makeIcon(...)`. | State | Trigger | Visual | | --------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------- | -| Idle | Default | Normal blink/wiggle animation | -| Paused | `isPaused=true` | Status item uses `appearsDisabled`; no motion | -| Voice wake (big ears) | Wake word heard | Ears scale to `1.9x` with `earHoles=true` (circular holes for readability); drops after silence | +| Idle | Default | Normal blink/wiggle animation; open eyes keep a glossy glint | +| Paused | `isPaused=true` | Antennae droop ("off duty") with open eyes; no motion | +| Sleeping | Gateway disconnected/unconfigured | Antennae droop and eyes close into `⌣ ⌣` lids; no motion | +| Celebrate | Message sent (`sendCelebrationTick`) | Eyes flash happy `∩ ∩` arcs for ~0.9s plus a leg kick | +| Voice wake (big ears) | Wake word heard | Antennae perk up straight and taller (`earScale=1.9`); drops after silence | | Working | `isWorking=true` or an active `IconState` | Faster leg wiggle (`legWiggle` up to `1.0`) plus a small horizontal offset; additive to idle wiggle | A tool-activity badge (SF Symbol puck, e.g. `chevron.left.slash.chevron.right` for exec) can render on top of the same critter icon when a session has an active job or tool. That badge comes from `IconState`/`ActivityKind`; see [Menu bar](/platforms/mac/menu-bar) for the full state model. @@ -30,7 +32,8 @@ A tool-activity badge (SF Symbol puck, e.g. `chevron.left.slash.chevron.right` f ## Shapes and sizes - Canvas: 18x18pt template image, rendered into a 36x36px bitmap backing store (2x) so the icon stays crisp on Retina. -- Ear scale defaults to `1.0`; voice boost sets `earScale=1.9` and `earHoles=true` without changing the overall frame. +- Ear scale defaults to `1.0`; voice boost sets `earScale=1.9` without changing the overall frame. +- `antennaDroop` (0-1) folds the antennae down for the paused and sleeping poses. - Leg scurry uses `legWiggle` up to `1.0` with a small horizontal jiggle. ## Behavioral notes