diff --git a/apps/macos/Sources/Clawdis/MenuBar.swift b/apps/macos/Sources/Clawdis/MenuBar.swift index 03d60aba6..5ddebea24 100644 --- a/apps/macos/Sources/Clawdis/MenuBar.swift +++ b/apps/macos/Sources/Clawdis/MenuBar.swift @@ -192,7 +192,8 @@ private struct CritterStatusLabel: View { blink: self.blinkAmount, legWiggle: max(self.legWiggle, self.isWorking ? 0.6 : 0), earWiggle: self.earWiggle, - earScale: self.earBoostActive ? 1.9 : 1.0)) + earScale: self.earBoostActive ? 1.9 : 1.0, + earHoles: self.earBoostActive)) .frame(width: 18, height: 16) .rotationEffect(.degrees(self.wiggleAngle), anchor: .center) .offset(x: self.wiggleOffset) @@ -342,7 +343,8 @@ enum CritterIconRenderer { blink: CGFloat, legWiggle: CGFloat = 0, earWiggle: CGFloat = 0, - earScale: CGFloat = 1) -> NSImage + earScale: CGFloat = 1, + earHoles: Bool = false) -> NSImage { let image = NSImage(size: size) image.lockFocus() @@ -362,6 +364,16 @@ enum CritterIconRenderer { let earW = w * 0.22 let earH = bodyH * 0.66 * earScale * (1 - 0.08 * abs(earWiggle)) let earCorner = earW * 0.24 + let leftEarRect = CGRect( + x: bodyX - earW * 0.55 + earWiggle, + y: bodyY + bodyH * 0.08 + earWiggle * 0.4, + width: earW, + height: earH) + let rightEarRect = CGRect( + x: bodyX + bodyW - earW * 0.45 - earWiggle, + y: bodyY + bodyH * 0.08 - earWiggle * 0.4, + width: earW, + height: earH) let legW = w * 0.11 let legH = h * 0.26 @@ -385,20 +397,12 @@ enum CritterIconRenderer { cornerHeight: bodyCorner, transform: nil)) ctx.addPath(CGPath( - roundedRect: CGRect( - x: bodyX - earW * 0.55 + earWiggle, - y: bodyY + bodyH * 0.08 + earWiggle * 0.4, - width: earW, - height: earH), + roundedRect: leftEarRect, cornerWidth: earCorner, cornerHeight: earCorner, transform: nil)) ctx.addPath(CGPath( - roundedRect: CGRect( - x: bodyX + bodyW - earW * 0.45 - earWiggle, - y: bodyY + bodyH * 0.08 - earWiggle * 0.4, - width: earW, - height: earH), + roundedRect: rightEarRect, cornerWidth: earCorner, cornerHeight: earCorner, transform: nil)) @@ -416,6 +420,33 @@ enum CritterIconRenderer { let leftCenter = CGPoint(x: w / 2 - eyeOffset, y: eyeY) let rightCenter = CGPoint(x: w / 2 + eyeOffset, y: eyeY) + if earHoles || earScale > 1.05 { + let holeW = earW * 0.6 + let holeH = earH * 0.46 + let holeCorner = holeW * 0.34 + let leftHoleRect = CGRect( + x: leftEarRect.midX - holeW / 2, + y: leftEarRect.midY - holeH / 2 + earH * 0.04, + width: holeW, + height: holeH) + let rightHoleRect = CGRect( + x: rightEarRect.midX - holeW / 2, + y: rightEarRect.midY - holeH / 2 + earH * 0.04, + width: holeW, + height: holeH) + + ctx.addPath(CGPath( + roundedRect: leftHoleRect, + cornerWidth: holeCorner, + cornerHeight: holeCorner, + transform: nil)) + ctx.addPath(CGPath( + roundedRect: rightHoleRect, + cornerWidth: holeCorner, + cornerHeight: holeCorner, + transform: nil)) + } + let left = CGMutablePath() left.move(to: CGPoint(x: leftCenter.x - eyeW / 2, y: leftCenter.y - eyeH)) left.addLine(to: CGPoint(x: leftCenter.x + eyeW / 2, y: leftCenter.y)) diff --git a/docs/mac/icon.md b/docs/mac/icon.md index 06a102d10..2aad77d56 100644 --- a/docs/mac/icon.md +++ b/docs/mac/icon.md @@ -4,7 +4,7 @@ Author: steipete · Updated: 2025-12-06 · Scope: macOS app (`apps/macos`) - **Idle:** Normal icon animation (blink, occasional wiggle). - **Paused:** Status item uses `appearsDisabled`; no motion. -- **Voice trigger (big ears):** Voice wake detector calls `AppState.triggerVoiceEars()` → `earBoostActive=true` for ~5s. Ears scale up (1.9x) then auto-reset. Only fired from the in-app voice pipeline. +- **Voice trigger (big ears):** Voice wake detector calls `AppState.triggerVoiceEars()` → `earBoostActive=true` for ~5s. Ears scale up (1.9x), get circular ear holes for readability, then auto-reset. Only fired from the in-app voice pipeline. - **Working (agent running):** `AppState.isWorking=true` drives a “tail/leg scurry” micro-motion: faster leg wiggle and slight offset while work is in-flight. Currently toggled around WebChat agent runs; add the same toggle around other long tasks when you wire them. Wiring points @@ -12,8 +12,8 @@ Wiring points - Agent activity: set `AppStateStore.shared.setWorking(true/false)` around work spans (already done in WebChat agent call). Keep spans short and reset in `defer` blocks to avoid stuck animations. Shapes & sizes -- Base icon drawn in `CritterIconRenderer.makeIcon(blink:legWiggle:earWiggle:earScale:)`. -- Ear scale defaults to `1.0`; voice boost sets `earScale=1.9` without changing overall frame (18×16pt template image). +- Base icon drawn in `CritterIconRenderer.makeIcon(blink:legWiggle:earWiggle:earScale:earHoles:)`. +- Ear scale defaults to `1.0`; voice boost sets `earScale=1.9` and toggles `earHoles=true` without changing overall frame (18×16pt template image). - Scurry uses leg wiggle up to ~1.0 with a small horizontal jiggle; it’s additive to any existing idle wiggle. Behavioral notes