Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

slab/menubar-swift: polygon icon — N sessions = N-sided shape, per-session hue, all-stale blink

- Replace stacked-bars icon with a polygon: 1 = line, 2 = parallel bars,
3+ = regular n-gon (triangle, square, pentagon, …) up to 8 sides.
- 22×22 canvas (was 18×16), rendered at 2x so diagonal edges stay crisp.
- Each edge takes its session's stable hue (FNV-1a of sessionId), so
identity persists across refreshes and across state transitions.
- Whole shape rotates while any session is non-stale; rotation speed
scales with awaitingCount. Awaiting edges pulse brightness.
- When every session has ended (all stale), rotation freezes, color
homogenizes to gray, and the polygon blinks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+162 -55
+24 -11
slab/menubar-swift/Sources/SlabMenubar/AppDelegate.swift
··· 3 3 final class AppDelegate: NSObject, NSApplicationDelegate { 4 4 private var statusItem: NSStatusItem! 5 5 private var refreshTimer: Timer? 6 - private var rainbowTimer: Timer? 6 + private var animTimer: Timer? 7 7 private var rainbowPhase: CGFloat = 0 8 + private var rotationPhase: CGFloat = 0 8 9 private var mailTickCount = 0 9 10 private var mailPending = false 10 11 private var mailSyncing = false ··· 40 41 state = StateSnapshot.gather() 41 42 42 43 updateIcon() 43 - updateRainbowTimer() 44 + updateAnimTimer() 44 45 45 46 mailTickCount += 1 46 47 if mailTickCount >= 15 && !mailPending && !mailSyncing { ··· 56 57 // Don't tint our color image — contentTintColor would otherwise 57 58 // recolor non-template images on macOS 10.14+. 58 59 button.contentTintColor = nil 59 - button.image = IconRenderer.image(for: state, phase: rainbowPhase) 60 - // When the stack icon is showing, the bar count communicates the 61 - // number — only show a numeric tail for subagents (which the stack 60 + button.image = IconRenderer.image(for: state, phase: rainbowPhase, rotation: rotationPhase) 61 + // When the polygon icon is showing, the edge count communicates the 62 + // number — only show a numeric tail for subagents (which the polygon 62 63 // doesn't represent) or the legacy fallback states. 63 64 if state.claudeSessions.isEmpty && state.totalActive > 0 { 64 65 button.title = " \(state.totalActive)" ··· 69 70 } 70 71 } 71 72 72 - private func updateRainbowTimer() { 73 - if state.anyAwaiting { 74 - if rainbowTimer == nil { 73 + private func updateAnimTimer() { 74 + // Run the animation timer whenever the polygon icon is showing, so 75 + // we can drive both the active-session rotation/pulse and the 76 + // all-stale blink. Rotation only advances while at least one 77 + // session is non-stale; pulse phase always advances (it drives both 78 + // awaiting brightness and stale blink). When the polygon goes away 79 + // entirely, stop the timer and reset phases. 80 + if !state.claudeSessions.isEmpty { 81 + if animTimer == nil { 75 82 let t = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in 76 83 guard let self = self else { return } 77 84 self.rainbowPhase = (self.rainbowPhase + 0.025).truncatingRemainder(dividingBy: 1.0) 85 + if self.state.anyActive { 86 + let rotSpeed = 0.004 + 0.012 * CGFloat(self.state.awaitingCount) 87 + self.rotationPhase = (self.rotationPhase + rotSpeed) 88 + .truncatingRemainder(dividingBy: .pi * 2) 89 + } 78 90 self.updateIcon() 79 91 } 80 92 RunLoop.main.add(t, forMode: .common) 81 - rainbowTimer = t 93 + animTimer = t 82 94 } 83 - } else if let t = rainbowTimer { 95 + } else if let t = animTimer { 84 96 t.invalidate() 85 - rainbowTimer = nil 97 + animTimer = nil 86 98 rainbowPhase = 0 99 + rotationPhase = 0 87 100 } 88 101 } 89 102
+137 -44
slab/menubar-swift/Sources/SlabMenubar/IconRenderer.swift
··· 2 2 3 3 enum IconRenderer { 4 4 /// Render the menubar icon. When there are any active Claude sessions we 5 - /// draw a custom stack — one colored horizontal bar per session, colored 6 - /// by its state (rainbow-pulsing for awaiting, steady cyan for working, 7 - /// dim gray for stale). Falls back to SF Symbols for idle / ambient / 5 + /// draw a regular polygon — one edge per session, colored by its state 6 + /// (rainbow-pulsing for awaiting, steady cyan for working, dim gray for 7 + /// stale). N=1 is a single line, N=2 is two parallel bars, N≥3 is the 8 + /// matching n-gon (triangle, square, pentagon, …). The whole shape 9 + /// rotates slowly while any session is non-stale, faster with more 10 + /// awaiting work. Falls back to SF Symbols for idle / ambient / 8 11 /// lid-closed states. 9 - static func image(for state: StateSnapshot, phase: CGFloat = 0) -> NSImage { 12 + static func image(for state: StateSnapshot, phase: CGFloat = 0, rotation: CGFloat = 0) -> NSImage { 10 13 if !state.claudeSessions.isEmpty { 11 - return stackImage(state: state, phase: phase) 14 + return polygonImage(state: state, phase: phase, rotation: rotation) 12 15 } 13 16 14 17 let name: String ··· 39 42 return configured 40 43 } 41 44 42 - /// Draw a horizontal stack: one bar per active session. Bars stack from 43 - /// bottom up (most-urgent at the top, since the reader sorts awaiting 44 - /// first). If we have more sessions than fit, the topmost bar gets a 45 - /// little "+" tick to indicate overflow. 46 - private static func stackImage(state: StateSnapshot, phase: CGFloat) -> NSImage { 47 - let pixelsW = 18 48 - let pixelsH = 16 49 - let size = NSSize(width: pixelsW, height: pixelsH) 45 + /// Cap the number of distinct polygon edges. Beyond this we still draw 46 + /// the cap polygon and add a small "+" tick in the center to indicate 47 + /// overflow. 48 + private static let maxSides = 8 49 + 50 + private static func polygonImage(state: StateSnapshot, phase: CGFloat, rotation: CGFloat) -> NSImage { 51 + // Square canvas sized to fill the menubar slot. macOS menubar 52 + // thickness is 22–24pt depending on version; 22×22 fits the slot 53 + // edge-to-edge without clipping. 54 + let pointsW: CGFloat = 22 55 + let pointsH: CGFloat = 22 56 + // Render at 2x so diagonal polygon edges stay crisp on retina. 57 + let scale: CGFloat = 2 58 + let pixelsW = Int(pointsW * scale) 59 + let pixelsH = Int(pointsH * scale) 50 60 51 61 // Explicit deviceRGB bitmap rep — without this, NSImage(size:) + 52 62 // lockFocus produces a rep that the menubar interprets as a template 53 - // (monochrome) so all our colored bars render as white. 63 + // (monochrome) so all our colored edges render as white. 54 64 guard let rep = NSBitmapImageRep( 55 65 bitmapDataPlanes: nil, 56 66 pixelsWide: pixelsW, ··· 65 75 ) else { 66 76 return fallbackImage() 67 77 } 78 + rep.size = NSSize(width: pointsW, height: pointsH) 68 79 69 80 NSGraphicsContext.saveGraphicsState() 70 81 NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) ··· 72 83 NSGraphicsContext.restoreGraphicsState() 73 84 } 74 85 75 - let maxVisible = 5 76 - let barH: CGFloat = 2 77 - let gap: CGFloat = 1 78 - let barW: CGFloat = 14 79 - 80 86 let sessions = state.claudeSessions 81 - let visible = Array(sessions.prefix(maxVisible)) 82 - let overflow = sessions.count - visible.count 87 + let visible = Array(sessions.prefix(maxSides)) 88 + let n = visible.count 89 + let overflow = sessions.count - n 90 + // When every session has ended (all stale), the polygon goes 91 + // homogeneous and blinks instead of carrying per-session colors. 92 + let allStale = !visible.isEmpty && visible.allSatisfy { $0.state == .stale } 83 93 84 - let stackH = CGFloat(visible.count) * barH + CGFloat(max(0, visible.count - 1)) * gap 85 - let originY = (size.height - stackH) / 2 86 - let originX = (size.width - barW) / 2 94 + let cx = pointsW / 2.0 95 + let cy = pointsH / 2.0 96 + // Bounding circle radius. Leaves ~1.5pt for line width + round caps 97 + // so the shape kisses the canvas edges but never clips. 98 + let radius: CGFloat = 9.5 99 + let lineWidth: CGFloat = 1.6 87 100 88 - for (i, session) in visible.enumerated() { 89 - let fromTop = CGFloat(i) 90 - let y = originY + stackH - barH - fromTop * (barH + gap) 91 - let rect = NSRect(x: originX, y: y, width: barW, height: barH) 92 - colorFor(session: session, indexFromTop: Int(fromTop), phase: phase).setFill() 93 - NSBezierPath(rect: rect).fill() 101 + if n == 1 { 102 + // Single horizontal line, rotated. 103 + let session = visible[0] 104 + drawSegment( 105 + center: NSPoint(x: cx, y: cy), 106 + length: 2 * radius, 107 + angle: rotation, 108 + color: colorFor(session: session, indexFromTop: 0, phase: phase, allStale: allStale), 109 + lineWidth: lineWidth 110 + ) 111 + } else if n == 2 { 112 + // Two parallel bars, pivoting together. The pair tilts as one 113 + // rigid body — bars stay parallel and equidistant from center. 114 + // Bar corners stay inside the bounding circle: with length L 115 + // and half-offset h, sqrt((L/2)² + h²) ≤ radius. 116 + let half: CGFloat = 3.5 117 + let length: CGFloat = 17 118 + let nx = -sin(rotation) 119 + let ny = cos(rotation) 120 + for (i, session) in visible.enumerated() { 121 + let sign: CGFloat = (i == 0) ? 1 : -1 122 + let center = NSPoint(x: cx + nx * half * sign, y: cy + ny * half * sign) 123 + drawSegment( 124 + center: center, 125 + length: length, 126 + angle: rotation, 127 + color: colorFor(session: session, indexFromTop: i, phase: phase, allStale: allStale), 128 + lineWidth: lineWidth 129 + ) 130 + } 131 + } else { 132 + // Regular n-gon. Vertices offset by π/n so the midpoint of edge 133 + // 0 sits at the top — ensures triangles point up, squares are 134 + // squares (not diamonds), etc. Edge i carries session[i]'s 135 + // color. 136 + var vertices: [NSPoint] = [] 137 + vertices.reserveCapacity(n) 138 + for k in 0..<n { 139 + let theta = -CGFloat.pi / 2 - CGFloat.pi / CGFloat(n) 140 + + 2 * CGFloat.pi * CGFloat(k) / CGFloat(n) 141 + + rotation 142 + vertices.append(NSPoint(x: cx + radius * cos(theta), y: cy + radius * sin(theta))) 143 + } 144 + for (i, session) in visible.enumerated() { 145 + let a = vertices[i] 146 + let b = vertices[(i + 1) % n] 147 + let path = NSBezierPath() 148 + path.move(to: a) 149 + path.line(to: b) 150 + path.lineWidth = lineWidth 151 + path.lineCapStyle = .round 152 + colorFor(session: session, indexFromTop: i, phase: phase, allStale: allStale).setStroke() 153 + path.stroke() 154 + } 94 155 } 95 156 96 - if overflow > 0, let top = visible.first { 97 - let y = originY + stackH - barH 98 - colorFor(session: top, indexFromTop: 0, phase: phase).setFill() 99 - NSBezierPath(rect: NSRect(x: originX + barW + 1, y: y - 1, width: 1, height: 4)).fill() 100 - NSBezierPath(rect: NSRect(x: originX + barW, y: y, width: 3, height: 1)).fill() 157 + if overflow > 0 { 158 + // Tiny "+" in the center indicates sessions beyond the cap. 159 + NSColor.white.setFill() 160 + NSBezierPath(rect: NSRect(x: cx - 1.5, y: cy - 0.5, width: 3, height: 1)).fill() 161 + NSBezierPath(rect: NSRect(x: cx - 0.5, y: cy - 1.5, width: 1, height: 3)).fill() 101 162 } 102 163 103 - let img = NSImage(size: size) 164 + let img = NSImage(size: NSSize(width: pointsW, height: pointsH)) 104 165 img.addRepresentation(rep) 105 166 img.isTemplate = false 106 167 return img 107 168 } 108 169 109 - private static func colorFor(session: ClaudeSession, indexFromTop: Int, phase: CGFloat) -> NSColor { 170 + private static func drawSegment(center: NSPoint, length: CGFloat, angle: CGFloat, color: NSColor, lineWidth: CGFloat) { 171 + let dx = cos(angle) * length / 2 172 + let dy = sin(angle) * length / 2 173 + let path = NSBezierPath() 174 + path.move(to: NSPoint(x: center.x - dx, y: center.y - dy)) 175 + path.line(to: NSPoint(x: center.x + dx, y: center.y + dy)) 176 + path.lineWidth = lineWidth 177 + path.lineCapStyle = .round 178 + color.setStroke() 179 + path.stroke() 180 + } 181 + 182 + private static func colorFor(session: ClaudeSession, indexFromTop: Int, phase: CGFloat, allStale: Bool) -> NSColor { 183 + // Hue is anchored to the session's identity — same session keeps 184 + // the same color across refreshes and across state changes, so the 185 + // ring is readable: "the orange edge that was awaiting is now 186 + // working" instead of "everything just got reshuffled." 187 + let hue = stableHue(for: session.sessionId) 110 188 switch session.state { 111 189 case .awaiting: 112 - // Rainbow hue rotation, pulsing brightness. Each awaiting bar is 113 - // offset in hue by its position so multiple awaiting sessions 114 - // are visually distinct. 190 + // Steady hue, pulsing brightness — reads as urgent without 191 + // losing identity. 115 192 let pulse = 0.55 + 0.45 * (0.5 + 0.5 * cos(phase * .pi * 4)) 116 - var hue = (phase + CGFloat(indexFromTop) * 0.15).truncatingRemainder(dividingBy: 1.0) 117 - if hue < 0 { hue += 1 } 118 193 return NSColor(deviceHue: hue, saturation: 0.95, brightness: pulse, alpha: 1.0) 119 194 case .working: 120 - // Calm cyan — steady, clearly distinct from rainbow. 121 - return NSColor(deviceHue: 0.52, saturation: 0.55, brightness: 0.85, alpha: 1.0) 195 + // Same hue, calm and steady. 196 + return NSColor(deviceHue: hue, saturation: 0.55, brightness: 0.85, alpha: 1.0) 122 197 case .stale: 198 + if allStale { 199 + // Every session is over — homogenize color across all 200 + // edges and blink so the icon reads as "done, attention 201 + // optional" rather than just sitting there gray. 202 + let blink = 0.5 + 0.5 * cos(phase * .pi * 2) 203 + return NSColor(deviceWhite: 0.30 + 0.45 * blink, alpha: 1.0) 204 + } 123 205 return NSColor(deviceWhite: 0.45, alpha: 1.0) 124 206 } 207 + } 208 + 209 + /// Deterministic FNV-1a hash → hue in [0, 1). Stable across launches 210 + /// for a given session id. 211 + private static func stableHue(for sessionId: String) -> CGFloat { 212 + var h: UInt64 = 0xcbf29ce484222325 213 + for byte in sessionId.utf8 { 214 + h ^= UInt64(byte) 215 + h = h &* 0x100000001b3 216 + } 217 + return CGFloat(h % 360) / 360.0 125 218 } 126 219 127 220 private static func fallbackImage() -> NSImage {
+1
slab/menubar-swift/Sources/SlabMenubar/StateSnapshot.swift
··· 13 13 var hasWork: Bool { totalActive > 0 } 14 14 var awaitingCount: Int { claudeSessions.filter { $0.state == .awaiting }.count } 15 15 var anyAwaiting: Bool { awaitingCount > 0 } 16 + var anyActive: Bool { claudeSessions.contains(where: { $0.state != .stale }) } 16 17 17 18 var statusLine: String { 18 19 if anyAwaiting { return "\(awaitingCount) awaiting · \(totalActive) active" }