BroV look: swamp-zombie unibrow character, new app and menu bar icons, BroV naming in UI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
maksarsanjeev
2026-10-06 19:53:47 +03:00
parent 0f77ec8704
commit 7d76986bc0
22 changed files with 422 additions and 54 deletions
+4 -4
View File
@@ -28,13 +28,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private func setupMenuBarItem() {
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
guard let button = statusItem?.button else { return }
button.image = NSImage(named: "MenuBarIcon") ?? NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "Coucou")
button.image = NSImage(named: "MenuBarIcon") ?? NSImage(systemSymbolName: "circle.fill", accessibilityDescription: "BroV")
button.image?.size = NSSize(width: 24, height: 18)
button.image?.accessibilityDescription = "Coucou"
button.image?.accessibilityDescription = "BroV"
button.image?.isTemplate = true
let menu = NSMenu()
menu.addItem(withTitle: "Open Coucou", action: #selector(openIsland), keyEquivalent: "")
menu.addItem(withTitle: "Open BroV", action: #selector(openIsland), keyEquivalent: "")
menu.addItem(.separator())
menu.addItem(withTitle: "Settings…", action: #selector(openSettings), keyEquivalent: ",")
menu.addItem(.separator())
@@ -69,7 +69,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let win = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 720, height: 560),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered, defer: false)
win.title = "Settings — Coucou"
win.title = "Settings — BroV"
let host = NSHostingView(rootView: SettingsView())
host.sizingOptions = [.minSize]
win.contentView = host
+2 -2
View File
@@ -198,9 +198,9 @@ final class ClaudeService {
/// neutral otherwise — same wording as the Windows build.
private nonisolated static func makeSystemPrompt() -> String {
let opening = if let firstName = resolveUserFirstName() {
"You are Mochi, \(firstName)'s personal AI assistant embedded in the notch of their Mac."
"You are BroV, \(firstName)'s personal AI assistant embedded in the notch of their Mac."
} else {
"You are Mochi, a personal AI assistant embedded in the notch of the user's Mac."
"You are BroV, a personal AI assistant embedded in the notch of the user's Mac."
}
return """
\(opening) \
+28 -28
View File
@@ -19,7 +19,7 @@ final class HookServer: @unchecked Sendable {
static var socketPath: String {
#if APPSTORE
// Container home root keeps path ≤ 103 bytes (sun_path limit on macOS is 104 incl. NUL)
// /Users/louis/Library/Containers/fr.louisraille.Coucou/Data/nb.sock = 66 bytes ✓
// /Users/louis/Library/Containers/fr.louisraille.BroV/Data/nb.sock = 66 bytes ✓
return NSHomeDirectory() + "/nb.sock"
#else
return supportDir.appendingPathComponent("nb.sock").path
@@ -1093,7 +1093,7 @@ final class HookServer: @unchecked Sendable {
// MARK: - Outdated hook detection
/// Returns true if settings.json has a Coucou hook that needs updating:
/// Returns true if settings.json has a BroV hook that needs updating:
/// either a PermissionRequest hook with timeout < 120s, or the AskUserQuestion
/// PreToolUse matcher is missing (requires Claude Code 2.1.85+).
static func hooksNeedUpdate() -> Bool {
@@ -1104,8 +1104,8 @@ final class HookServer: @unchecked Sendable {
let hooks = settings["hooks"] as? [String: Any] else {
return false
}
// Track whether any Coucou hook is installed at all
var hasCoucouHooks = false
// Track whether any BroV hook is installed at all
var hasBroVHooks = false
if let permReqHooks = hooks["PermissionRequest"] as? [[String: Any]] {
for matcher in permReqHooks {
@@ -1113,7 +1113,7 @@ final class HookServer: @unchecked Sendable {
for hook in hookList {
if let cmd = hook["command"] as? String,
cmd.contains("NotchBuddy") || cmd.contains("coucou") {
hasCoucouHooks = true
hasBroVHooks = true
if let timeout = hook["timeout"] as? Int, timeout < 120 { return true }
}
}
@@ -1122,7 +1122,7 @@ final class HookServer: @unchecked Sendable {
}
// Check that the AskUserQuestion PreToolUse entry exists
if hasCoucouHooks {
if hasBroVHooks {
let preToolHooks = hooks["PreToolUse"] as? [[String: Any]] ?? []
let hasAskEntry = preToolHooks.contains { m in
(m["matcher"] as? String) == "AskUserQuestion"
@@ -1540,7 +1540,7 @@ final class HookServer: @unchecked Sendable {
label: "~/.gemini/settings.json")
if let raw = settings["hooks"], !(raw is [String: Any]) {
throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" has an unexpected type — Coucou has not touched it."
NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" has an unexpected type — BroV has not touched it."
])
}
let base = hookBase()
@@ -1557,7 +1557,7 @@ final class HookServer: @unchecked Sendable {
for (geminiEvent, normalizedEvent, timeout) in events {
if let raw = hooks[geminiEvent], !(raw is [[String: Any]]) {
throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\"[\"\(geminiEvent)\"] has an unexpected type — Coucou has not touched it."
NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\"[\"\(geminiEvent)\"] has an unexpected type — BroV has not touched it."
])
}
var groups = hooks[geminiEvent] as? [[String: Any]] ?? []
@@ -1581,7 +1581,7 @@ final class HookServer: @unchecked Sendable {
label: "~/.gemini/settings.json")
if let raw = settings["hooks"], !(raw is [String: Any]) {
throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" has an unexpected type — Coucou has not touched it."
NSLocalizedDescriptionKey: "~/.gemini/settings.json: \"hooks\" has an unexpected type — BroV has not touched it."
])
}
if var hooks = settings["hooks"] as? [String: Any] {
@@ -1679,12 +1679,12 @@ final class HookServer: @unchecked Sendable {
do { data = try Data(contentsOf: url) }
catch {
throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(label) cannot be read — Coucou has not touched it."
NSLocalizedDescriptionKey: "\(label) cannot be read — BroV has not touched it."
])
}
guard let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else {
throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(label) is not valid JSON — Coucou has not touched it."
NSLocalizedDescriptionKey: "\(label) is not valid JSON — BroV has not touched it."
])
}
return obj
@@ -1735,7 +1735,7 @@ final class HookServer: @unchecked Sendable {
FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".codex/hooks.json")
}
/// True when ~/.codex/hooks.json already routes Codex events to Coucou's nb-hook.
/// True when ~/.codex/hooks.json already routes Codex events to BroV's nb-hook.
static func codexHooksInstalled() -> Bool {
guard let data = try? Data(contentsOf: codexHooksURL),
let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
@@ -1790,7 +1790,7 @@ final class HookServer: @unchecked Sendable {
var root = try Self.strictReadJSONObject(at: Self.codexHooksURL, label: "~/.codex/hooks.json")
if let raw = root["hooks"], !(raw is [String: Any]) {
throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" has an unexpected type — Coucou has not touched it."
NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" has an unexpected type — BroV has not touched it."
])
}
let base = hookBase()
@@ -1800,7 +1800,7 @@ final class HookServer: @unchecked Sendable {
("SessionStart", 10, nil),
("UserPromptSubmit", 10, nil),
("PreToolUse", 10, nil),
("PermissionRequest", 120, "Waiting for your answer in the notch (Coucou)"),
("PermissionRequest", 120, "Waiting for your answer in the notch (BroV)"),
("PostToolUse", 10, nil),
("Stop", 10, nil),
("SubagentStart", 10, nil),
@@ -1812,11 +1812,11 @@ final class HookServer: @unchecked Sendable {
for (event, timeout, statusMsg) in events {
if let raw = hooks[event], !(raw is [[String: Any]]) {
throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\"[\"\(event)\"] has an unexpected type — Coucou has not touched it."
NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\"[\"\(event)\"] has an unexpected type — BroV has not touched it."
])
}
var groups = hooks[event] as? [[String: Any]] ?? []
// Remove existing Coucou entries
// Remove existing BroV entries
groups = removeNbHookEntries(from: groups)
var hookEntry: [String: Any] = [
"type": "command",
@@ -1836,7 +1836,7 @@ final class HookServer: @unchecked Sendable {
var root = try Self.strictReadJSONObject(at: Self.codexHooksURL, label: "~/.codex/hooks.json")
if let raw = root["hooks"], !(raw is [String: Any]) {
throw NSError(domain: "Coucou", code: 2, userInfo: [
NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" has an unexpected type — Coucou has not touched it."
NSLocalizedDescriptionKey: "~/.codex/hooks.json: \"hooks\" has an unexpected type — BroV has not touched it."
])
}
if var hooks = root["hooks"] as? [String: Any] {
@@ -1874,7 +1874,7 @@ extension Notification.Name {
private let nbHookShellWrapper = """
#!/bin/sh
# Coucou hook relay — always exits 0, never blocks Claude Code
# BroV hook relay — always exits 0, never blocks Claude Code
HOOK_DIR="$(dirname "$0")"
if xcode-select -p >/dev/null 2>&1; then
out=$(/usr/bin/python3 "$HOOK_DIR/nb-hook.py" "$@" 2>/dev/null)
@@ -1890,8 +1890,8 @@ exit 0
private let nbHookPythonGitHub = """
#!/usr/bin/env python3
# nb-hook.py — Coucou hook relay for Claude Code and third-party agents (GitHub version)
# Reads JSON from stdin, forwards to Coucou via Unix socket, translates response.
# nb-hook.py — BroV hook relay for Claude Code and third-party agents (GitHub version)
# Reads JSON from stdin, forwards to BroV via Unix socket, translates response.
import sys, json, os, socket
def normalize_event(name):
@@ -1948,7 +1948,7 @@ def main():
'~/Library/Application Support/NotchBuddy/nb.sock'
)
# --statusline mode: relay rate_limits to Coucou, then delegate to saved previous
# --statusline mode: relay rate_limits to BroV, then delegate to saved previous
if '--statusline' in sys.argv[1:]:
relay = {
'coucou_kind': 'statusline',
@@ -2074,7 +2074,7 @@ def main():
# socket_path is already defined above
if event == 'PermissionRequest':
# Block and wait for Coucou's decision (Claude Code allows up to 120s)
# Block and wait for BroV's decision (Claude Code allows up to 120s)
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(118)
@@ -2115,7 +2115,7 @@ def main():
sys.stdout.flush()
sys.exit(0)
elif decision == 'deny':
out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'deny', 'message': 'Denied from Coucou'}}}
out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'deny', 'message': 'Denied from BroV'}}}
sys.stdout.write(json.dumps(out) + '\\n')
sys.stdout.flush()
sys.exit(0)
@@ -2157,7 +2157,7 @@ sys.exit(0)
private let nbHookPythonAppStore = """
#!/usr/bin/env python3
# nb-hook.py — Coucou (App Store) hook relay for Claude Code and third-party agents
# nb-hook.py — BroV (App Store) hook relay for Claude Code and third-party agents
# Socket lives inside the sandboxed container; script runs outside the sandbox.
import sys, json, os, socket
@@ -2212,10 +2212,10 @@ def main():
return
socket_path = os.path.expanduser(
'~/Library/Containers/fr.louisraille.Coucou/Data/nb.sock'
'~/Library/Containers/fr.louisraille.BroV/Data/nb.sock'
)
# --statusline mode: relay rate_limits to Coucou, then delegate to saved previous
# --statusline mode: relay rate_limits to BroV, then delegate to saved previous
if '--statusline' in sys.argv[1:]:
relay = {
'coucou_kind': 'statusline',
@@ -2340,7 +2340,7 @@ def main():
# socket_path is already defined above
if event == 'PermissionRequest':
# Block and wait for Coucou's decision (Claude Code allows up to 120s)
# Block and wait for BroV's decision (Claude Code allows up to 120s)
try:
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.settimeout(118)
@@ -2381,7 +2381,7 @@ def main():
sys.stdout.flush()
sys.exit(0)
elif decision == 'deny':
out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'deny', 'message': 'Denied from Coucou'}}}
out = {'hookSpecificOutput': {'hookEventName': 'PermissionRequest', 'decision': {'behavior': 'deny', 'message': 'Denied from BroV'}}}
sys.stdout.write(json.dumps(out) + '\\n')
sys.stdout.flush()
sys.exit(0)
@@ -3891,7 +3891,7 @@ struct MusicCardView: View {
.padding(.leading, 108)
.padding(.trailing, 36)
Text("Allow Coucou to control Music")
Text("Allow BroV to control Music")
.font(.system(size: 11))
.foregroundColor(Color(hex: "#8E939C"))
.padding(.leading, 108)
+9 -9
View File
@@ -119,7 +119,7 @@ struct SettingsView: View {
.resizable()
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 1) {
Text("Coucou")
Text("BroV")
.font(.system(size: 13, weight: .semibold))
Text(appVersion)
.font(.system(size: 11))
@@ -301,14 +301,14 @@ struct SettingsView: View {
VStack(alignment: .leading, spacing: 6) {
Toggle("Show my agent sessions on my iPhone", isOn: $iPhoneSyncEnabled)
.onChange(of: iPhoneSyncEnabled) { _, on in CloudProbe.shared.setEnabled(on) }
Text("Sends your sessions to your private iCloud for the Coucou iPhone app. Project names, commands and questions are encrypted with your iCloud keys. Turning it off deletes them from iCloud.")
Text("Sends your sessions to your private iCloud for the BroV iPhone app. Project names, commands and questions are encrypted with your iCloud keys. Turning it off deletes them from iCloud.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
Toggle("Move Mochi to my iPhone's Dynamic Island when my Mac is locked", isOn: $iPhoneLiveActivityEnabled)
.disabled(!iPhoneSyncEnabled)
.onChange(of: iPhoneLiveActivityEnabled) { _, on in LiveActivityRelay.shared.setEnabled(on) }
Text("Goes through the Coucou relay to Apple's push service. Only the agent's name and state are sent: no project name, command or path.")
Text("Goes through the BroV relay to Apple's push service. Only the agent's name and state are sent: no project name, command or path.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -332,7 +332,7 @@ struct SettingsView: View {
@ViewBuilder private var activePillsSection: some View {
GroupBox {
VStack(alignment: .leading, spacing: 10) {
Text("Choose the tools you use. Coucou only shows what you declare here.")
Text("Choose the tools you use. BroV only shows what you declare here.")
.font(.system(size: 11))
.foregroundColor(.secondary)
@@ -534,7 +534,7 @@ struct SettingsView: View {
GroupBox("Plan usage") {
VStack(alignment: .leading, spacing: 10) {
Text("Shows your Claude plan usage (5-hour and weekly limits) in the notch header. Coucou adds a status line relay to ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.")
Text("Shows your Claude plan usage (5-hour and weekly limits) in the notch header. BroV adds a status line relay to ~/.claude/settings.json. If you already have a status line, it keeps working as before. Pro and Max plans only.")
.font(.system(size: 11))
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -936,7 +936,7 @@ struct SettingsView: View {
private func installHooksAppStore() {
guard let claudeURL = pickClaudeFolder(prompt: "Select") else { return }
let alert = NSAlert()
alert.messageText = "Install Coucou hooks in ~/.claude?"
alert.messageText = "Install BroV hooks in ~/.claude?"
alert.informativeText = "Will write:\n• ~/.claude/coucou/nb-hook\n• ~/.claude/settings.json (backup created first)"
alert.addButton(withTitle: "Install")
alert.addButton(withTitle: "Cancel")
@@ -1036,7 +1036,7 @@ struct SettingsView: View {
pendingGeminiJSON = try HookServer.shared.previewGeminiHooks(install: install)
showGeminiDiff = true
statusMessage = "Review the JSON below before confirming."
} catch let e as NSError where e.domain == "CoucouNoop" {
} catch let e as NSError where e.domain == "BroVNoop" {
statusMessage = e.localizedDescription
} catch {
statusMessage = "❌ \(error.localizedDescription)"
@@ -1063,7 +1063,7 @@ struct SettingsView: View {
pendingAgyJSON = try HookServer.shared.previewAgyHooks(install: install)
showAgyDiff = true
statusMessage = "Review the JSON below before confirming."
} catch let e as NSError where e.domain == "CoucouNoop" {
} catch let e as NSError where e.domain == "BroVNoop" {
statusMessage = e.localizedDescription
} catch {
statusMessage = "❌ \(error.localizedDescription)"
@@ -1090,7 +1090,7 @@ struct SettingsView: View {
pendingCodexJSON = try HookServer.shared.previewCodexHooks(install: install)
showCodexDiff = true
statusMessage = "Review the JSON below before confirming."
} catch let e as NSError where e.domain == "CoucouNoop" {
} catch let e as NSError where e.domain == "BroVNoop" {
statusMessage = e.localizedDescription
} catch {
statusMessage = "❌ \(error.localizedDescription)"
+3 -3
View File
@@ -2,7 +2,7 @@ import Foundation
// MARK: - Shortcut Action
/// Every keyboard shortcut Coucou knows about.
/// Every keyboard shortcut BroV knows about.
/// The first 10 cases are *global* (registered with Carbon and effective from any app).
/// Island-local shortcuts are documented in `ShortcutLogic.islandShortcuts` but are
/// not managed here — they are handled by a local NSEvent monitor in IslandWindowController.
@@ -46,7 +46,7 @@ enum ShortcutAction: String, CaseIterable, Sendable {
case .nextPill: return "Next pill"
case .prevPill: return "Previous pill"
case .muteToggle: return "Mute / unmute sounds"
case .desktopToggle: return "Mochi on / off desktop"
case .desktopToggle: return "BroV on / off desktop"
case .wardrobeToggle: return "Open / close wardrobe"
}
}
@@ -159,7 +159,7 @@ enum ShortcutLogic {
return c
}
// MARK: - Conflict detection (within Coucou's own shortcuts)
// MARK: - Conflict detection (within BroV's own shortcuts)
/// Returns the set of actions whose (keyCode, nsFlags) pair is shared with another action.
static func duplicates(in specs: [ShortcutAction: ShortcutSpec]) -> Set<ShortcutAction> {
+215 -7
View File
@@ -79,9 +79,10 @@ enum MochiConst {
static let eyeH: CGFloat = 0.27
static let eyeSp: CGFloat = 0.37
static let eyeP: CGFloat = -0.12
static let baseTop = CGColor(red: 0.929, green: 0.929, blue: 0.937, alpha: 1) // #EDEDEF
static let baseBottom = CGColor(red: 0.769, green: 0.773, blue: 0.792, alpha: 1) // #C4C5CA
static let ink = CGColor(red: 0.102, green: 0.082, blue: 0.071, alpha: 1) // #1A1412
// BroV: swamp-zombie skin and dried-blood ink.
static let baseTop = CGColor(red: 0.659, green: 0.749, blue: 0.416, alpha: 1) // #A8BF6A
static let baseBottom = CGColor(red: 0.357, green: 0.451, blue: 0.251, alpha: 1) // #5B7340
static let ink = CGColor(red: 0.165, green: 0.039, blue: 0.039, alpha: 1) // #2A0A0A
static let miniInk = CGColor(red: 0.063, green: 0.075, blue: 0.102, alpha: 1) // #10131A
}
@@ -916,9 +917,13 @@ final class BotEngine: ObservableObject {
drawBlush(ctx: &ctx, path: bodyPath, rx: rx, ry: ry, R: R, blush: blushVal)
}
if showsBroVFace, R > 11 { drawBroVSkin(ctx: ctx, path: bodyPath, R: R, rx: rx, ry: ry) }
// Eyes
drawEyes(ctx: &ctx, path: bodyPath, R: R, rx: rx, ry: ry)
if showsBroVFace, R > 11 { drawBroVFace(ctx: ctx, path: bodyPath, R: R, rx: rx, ry: ry) }
// Mouth hole — dark pill cutout inside the box face
// Spec: left/right margins 0.10R, top margin 0.08R from box top (-0.94R)
if morph > 0.05 {
@@ -1135,12 +1140,14 @@ final class BotEngine: ObservableObject {
let tw = R * 1.0
let th = R * 0.94
let tr = R * 0.42
let lumpTime = CGFloat(CACurrentMediaTime())
var path = Path()
for i in 0...n {
let a = CGFloat(i) / CGFloat(n) * .pi * 2
let ca = cos(a), sa = sin(a)
let px0 = rx * (ca >= 0 ? pow(ca, expN) : -pow(-ca, expN))
let py0 = ry * (sa >= 0 ? pow(sa, expN) : -pow(-sa, expN))
let lump = broVLump(angle: a, time: lumpTime)
let px0 = rx * lump * (ca >= 0 ? pow(ca, expN) : -pow(-ca, expN))
let py0 = ry * lump * (sa >= 0 ? pow(sa, expN) : -pow(-sa, expN))
let px: CGFloat
let py: CGFloat
if morph < 0.005 {
@@ -1205,8 +1212,8 @@ final class BotEngine: ObservableObject {
ctx.fill(path, with: .color(Color(cgColor: bc)))
} else {
// Main bot: linear gradient body
let top: Color = pumpkinColors ? Color(hex: "#FFA94D") : Color(red: 0.929, green: 0.929, blue: 0.937)
let bot: Color = pumpkinColors ? Color(hex: "#E8590C") : Color(red: 0.769, green: 0.773, blue: 0.792)
let top: Color = pumpkinColors ? Color(hex: "#FFA94D") : Color(cgColor: MochiConst.baseTop)
let bot: Color = pumpkinColors ? Color(hex: "#E8590C") : Color(cgColor: MochiConst.baseBottom)
ctx.fill(path, with: .linearGradient(
Gradient(colors: [top, bot]),
startPoint: CGPoint(x: rx*0.7, y: -ry*0.85),
@@ -1307,6 +1314,9 @@ final class BotEngine: ObservableObject {
case .wide:
drawEyeShape(ctx: &ctx, shape: .pill, w: w*1.16, h: h*1.12, open: open, sd: sd, R: R)
case .pill where showsBroVFace:
drawBroVEye(ctx: &ctx, w: w, h: h, open: open, sd: sd, ink: ink)
case .pill:
let hh = max(h * open, w * 0.3)
var p = Path()
@@ -1657,3 +1667,201 @@ extension BadgeType: Equatable {
}
}
}
// MARK: - BroV face
//
// BroV ("бровь") is a lumpy swamp-zombie blob: one huge hairy unibrow, a bloodshot
// mismatched pair of eyes, a toothy grin with missing teeth, drool, warts and a
// stitched scar. Everything rides on the same engine (blink, gaze, emotes, states);
// mini bots and the upload box keep the plain look.
extension BotEngine {
/// Main bot only, and not while morphing into the upload box.
var showsBroVFace: Bool { !isMini && bodyColor == nil && morph < 0.3 && outfit != .pumpkin }
/// Slow, breathing bumps on the outline (main bot only).
func broVLump(angle a: CGFloat, time t: CGFloat) -> CGFloat {
guard !isMini, bodyColor == nil else { return 1 }
let k = 1 - min(1, morph * 2)
let bumps = 0.045 * sin(3 * a + t * 1.1)
+ 0.030 * sin(5 * a - t * 0.7 + 1.3)
+ 0.018 * sin(9 * a + 2.1)
return 1 + bumps * k
}
/// Warts and the stitched scar (under the eyes).
func drawBroVSkin(ctx: GraphicsContext, path: Path, R: CGFloat, rx: CGFloat, ry: CGFloat) {
var c = ctx
c.clip(to: path)
let shift = sin(yaw) * rx * 0.8
let a = 1 - morph / 0.3
// Warts: darker bumps with a tiny highlight.
let warts: [(CGFloat, CGFloat, CGFloat)] = [(-0.78, 0.30, 0.085), (0.70, 0.52, 0.065), (-0.30, 0.68, 0.05), (0.85, -0.10, 0.045)]
for (wx, wy, wr) in warts {
let p = CGPoint(x: wx * rx + shift, y: wy * ry)
var e = Path(); e.addEllipse(in: CGRect(x: p.x - wr * R, y: p.y - wr * R, width: wr * 2 * R, height: wr * 2 * R))
c.fill(e, with: .color(Color(hex: "#3F5229").opacity(0.85 * Double(a))))
var h = Path(); h.addEllipse(in: CGRect(x: p.x - wr * R * 0.35, y: p.y - wr * R * 0.6, width: wr * R * 0.5, height: wr * R * 0.4))
c.fill(h, with: .color(Color.white.opacity(0.25 * Double(a))))
}
// Stitched scar across the upper right of the head.
let s0 = CGPoint(x: rx * 0.20 + shift, y: -ry * 0.78)
let s1 = CGPoint(x: rx * 0.72 + shift, y: -ry * 0.40)
var scar = Path(); scar.move(to: s0)
scar.addQuadCurve(to: s1, control: CGPoint(x: rx * 0.55 + shift, y: -ry * 0.75))
c.stroke(scar, with: .color(Color(hex: "#6B2B2B").opacity(Double(a))), style: StrokeStyle(lineWidth: R * 0.05, lineCap: .round))
for i in 1...4 {
let t = CGFloat(i) / 5
let mx = (1 - t) * (1 - t) * s0.x + 2 * (1 - t) * t * (rx * 0.55 + shift) + t * t * s1.x
let my = (1 - t) * (1 - t) * s0.y + 2 * (1 - t) * t * (-ry * 0.75) + t * t * s1.y
var st = Path()
st.move(to: CGPoint(x: mx - R * 0.06, y: my - R * 0.07))
st.addLine(to: CGPoint(x: mx + R * 0.06, y: my + R * 0.07))
c.stroke(st, with: .color(Color(hex: "#2A0A0A").opacity(0.8 * Double(a))), style: StrokeStyle(lineWidth: R * 0.025, lineCap: .round))
}
}
/// Bloodshot sclera + small twitchy pupil. The left eye is bigger.
func drawBroVEye(ctx: inout GraphicsContext, w: CGFloat, h: CGFloat, open: CGFloat, sd: CGFloat, ink: Color) {
let big: CGFloat = sd < 0 ? 1.28 : 0.92
let sw = w * 1.75 * big
let sh = max(h * 1.45 * big * open, w * 0.25)
let sclera = Path(ellipseIn: CGRect(x: -sw / 2, y: -sh / 2, width: sw, height: sh))
ctx.fill(sclera, with: .radialGradient(
Gradient(colors: [Color(hex: "#F4EFC8"), Color(hex: "#E2C98E"), Color(hex: "#C9785F")]),
center: .zero, startRadius: 0, endRadius: sw * 0.6))
guard open > 0.25 else {
ctx.stroke(sclera, with: .color(ink), lineWidth: w * 0.12)
return
}
var inner = ctx
inner.clip(to: sclera)
// Veins from the edge toward the pupil.
for i in 0..<6 {
let ang = CGFloat(i) * 1.05 + sd * 0.4
var v = Path()
let r0 = sw * 0.52, r1 = sw * 0.2
v.move(to: CGPoint(x: cos(ang) * r0, y: sin(ang) * r0 * sh / sw))
v.addQuadCurve(to: CGPoint(x: cos(ang + 0.15) * r1, y: sin(ang + 0.15) * r1 * sh / sw),
control: CGPoint(x: cos(ang - 0.25) * sw * 0.36, y: sin(ang - 0.25) * sw * 0.36 * sh / sw))
inner.stroke(v, with: .color(Color(hex: "#C2182B").opacity(0.75)), lineWidth: max(0.6, w * 0.06))
}
// Pupil with a jittery twitch.
let t = CGFloat(CACurrentMediaTime())
let jx = sin(t * 23 + sd * 3) * w * 0.04
let jy = cos(t * 19 + sd) * w * 0.03
let pr = w * 0.42 * (sd < 0 ? 0.8 : 1.15)
inner.fill(Path(ellipseIn: CGRect(x: -pr + jx, y: -pr + jy, width: pr * 2, height: pr * 2)), with: .color(ink))
inner.fill(Path(ellipseIn: CGRect(x: -pr * 0.3 + jx + pr * 0.35, y: -pr * 0.3 + jy - pr * 0.35, width: pr * 0.5, height: pr * 0.5)),
with: .color(Color.white.opacity(0.85)))
ctx.stroke(sclera, with: .color(Color(hex: "#3A1010").opacity(0.7)), lineWidth: max(0.8, w * 0.07))
}
/// Unibrow above the eyes and the toothy mouth below.
func drawBroVFace(ctx: GraphicsContext, path: Path, R: CGFloat, rx: CGFloat, ry: CGFloat) {
var c = ctx
c.clip(to: path)
let alpha = Double(1 - morph / 0.3)
let shift = sin(yaw) * rx * 0.8
let t = CGFloat(CACurrentMediaTime())
let eyeY = -sin(MochiConst.eyeP + pitch) * ry
let ink = Color(cgColor: MochiConst.ink).opacity(alpha)
// ── Unibrow ───────────────────────────────────────────────
// Shape by mood: angry V, worried ∧, raised on questions, droopy asleep.
let mid: CGFloat // center offset (positive = lower)
let tilt: CGFloat // extra raise of the right end
switch state {
case .approval, .error: mid = 0.10; tilt = 0
case .ratelimit: mid = -0.08; tilt = 0
case .question, .searching: mid = 0; tilt = -0.14
case .sleeping: mid = -0.02; tilt = 0.06
case .dizzy: mid = sin(t * 6) * 0.08; tilt = cos(t * 5) * 0.08
default: mid = 0.02 + sin(t * 1.7) * 0.015; tilt = 0
}
let by = eyeY - R * 0.42
let half = R * 0.78
let L = CGPoint(x: -half + shift, y: by - R * 0.04)
let M = CGPoint(x: shift, y: by + mid * R)
let Rt = CGPoint(x: half + shift, y: by - R * 0.04 + tilt * R)
var brow = Path()
brow.move(to: L)
brow.addQuadCurve(to: M, control: CGPoint(x: (L.x + M.x) / 2, y: min(L.y, M.y) - R * 0.07))
brow.addQuadCurve(to: Rt, control: CGPoint(x: (M.x + Rt.x) / 2, y: min(M.y, Rt.y) - R * 0.07))
c.stroke(brow, with: .color(ink), style: StrokeStyle(lineWidth: R * 0.17, lineCap: .round, lineJoin: .round))
// Hairs sticking out.
for i in 0..<22 {
let u = CGFloat(i) / 21
let p = u < 0.5
? quad(L, CGPoint(x: (L.x + M.x) / 2, y: min(L.y, M.y) - R * 0.07), M, u * 2)
: quad(M, CGPoint(x: (M.x + Rt.x) / 2, y: min(M.y, Rt.y) - R * 0.07), Rt, (u - 0.5) * 2)
let ang = -CGFloat.pi / 2 + sin(CGFloat(i) * 2.7) * 0.7
let len = R * (0.10 + 0.05 * abs(sin(CGFloat(i) * 1.9)))
var hair = Path()
hair.move(to: p)
hair.addLine(to: CGPoint(x: p.x + cos(ang) * len, y: p.y + sin(ang) * len))
c.stroke(hair, with: .color(ink), style: StrokeStyle(lineWidth: max(0.7, R * 0.025), lineCap: .round))
}
// ── Mouth ─────────────────────────────────────────────────
let my = eyeY + R * 0.42
let mw: CGFloat
let mh: CGFloat
switch state {
case .approval: mw = R * 0.95; mh = R * 0.34 // screaming
case .error: mw = R * 0.70; mh = R * 0.10
case .finished: mw = R * 1.00; mh = R * 0.26
case .sleeping: mw = R * 0.30; mh = R * 0.14
case .working, .thinking, .searching:
mw = R * 0.80; mh = R * (0.12 + 0.05 * abs(sin(t * 7))) // chattering
default: mw = R * 0.88; mh = R * 0.20
}
let mx = shift
var mouth = Path()
mouth.move(to: CGPoint(x: mx - mw / 2, y: my - mh * 0.2))
mouth.addQuadCurve(to: CGPoint(x: mx + mw / 2, y: my - mh * 0.2), control: CGPoint(x: mx, y: my + mh * 0.25))
mouth.addQuadCurve(to: CGPoint(x: mx - mw / 2, y: my - mh * 0.2), control: CGPoint(x: mx, y: my + mh * 1.6))
mouth.closeSubpath()
c.fill(mouth, with: .color(Color(hex: "#3A0505").opacity(alpha)))
var teethCtx = c
teethCtx.clip(to: mouth)
let teeth = 7
for i in 0..<teeth where i != 2 && i != 5 { // missing teeth
let tx = mx - mw / 2 + mw * (CGFloat(i) + 0.5) / CGFloat(teeth)
let tw = mw / CGFloat(teeth) * 0.9
let ty = my - mh * 0.2 + (abs(tx - mx) / (mw / 2)) * -mh * 0.05 + mh * 0.12
var tooth = Path()
tooth.move(to: CGPoint(x: tx - tw / 2, y: ty - mh))
tooth.addLine(to: CGPoint(x: tx + tw / 2, y: ty - mh))
tooth.addLine(to: CGPoint(x: tx + tw * 0.1, y: ty + mh * 0.32))
tooth.closeSubpath()
teethCtx.fill(tooth, with: .color(Color(hex: i == 4 ? "#C9B458" : "#EDE6C8").opacity(alpha)))
}
c.stroke(mouth, with: .color(ink), lineWidth: max(0.8, R * 0.03))
// Drool from the left corner: grows, stretches, drops, repeats.
if state != .error {
let cycle = (t * 0.45).truncatingRemainder(dividingBy: 1)
let len = R * 0.35 * cycle
let dx = mx - mw * 0.32
let dy = my + mh * 0.25
var drool = Path()
drool.move(to: CGPoint(x: dx - R * 0.03, y: dy))
drool.addQuadCurve(to: CGPoint(x: dx, y: dy + len), control: CGPoint(x: dx - R * 0.02, y: dy + len * 0.6))
drool.addQuadCurve(to: CGPoint(x: dx + R * 0.03, y: dy), control: CGPoint(x: dx + R * 0.02, y: dy + len * 0.6))
ctx.fill(drool, with: .color(Color(hex: "#C8E6B0").opacity(0.75 * alpha)))
let dr = R * (0.035 + 0.03 * cycle)
ctx.fill(Path(ellipseIn: CGRect(x: dx - dr, y: dy + len - dr * 0.6, width: dr * 2, height: dr * 2.2)),
with: .color(Color(hex: "#C8E6B0").opacity(0.8 * alpha)))
}
}
private func quad(_ a: CGPoint, _ c: CGPoint, _ b: CGPoint, _ t: CGFloat) -> CGPoint {
let u = 1 - t
return CGPoint(x: u * u * a.x + 2 * u * t * c.x + t * t * b.x,
y: u * u * a.y + 2 * u * t * c.y + t * t * b.y)
}
}