diff --git a/Cargo.lock b/Cargo.lock
index fb195136..9def2471 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1857,6 +1857,32 @@ dependencies = [
"thiserror 2.0.20",
]
+[[package]]
+name = "iced_selector"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1442ccc30959cc48b3a624233521ef7f3bc3e06528b6fe306c3a775fbddc4fe"
+dependencies = [
+ "iced_core",
+]
+
+[[package]]
+name = "iced_test"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b56c26cc6e63958ce6be06a68fa3136fd5f646da6302ee12abf1a0f311dd0166"
+dependencies = [
+ "iced_futures",
+ "iced_program",
+ "iced_renderer",
+ "iced_runtime",
+ "iced_selector",
+ "nom",
+ "png 0.18.1",
+ "sha2",
+ "thiserror 2.0.20",
+]
+
[[package]]
name = "iced_tiny_skia"
version = "0.14.1"
@@ -5122,6 +5148,7 @@ dependencies = [
"chrono",
"futures",
"iced",
+ "iced_test",
"image",
"ksni",
"reqwest",
diff --git a/app/Localization/zh-Hans.lproj/Localizable.strings b/app/Localization/zh-Hans.lproj/Localizable.strings
index 5c1cbef5..d99ffe28 100644
--- a/app/Localization/zh-Hans.lproj/Localizable.strings
+++ b/app/Localization/zh-Hans.lproj/Localizable.strings
@@ -347,6 +347,32 @@
"Or let Claude Code answer, with your own Anthropic API key: add it above." = "也可以让 Claude Code 来回答,用你自己的 Anthropic API 密钥:在上面填入即可。";
"How to install Claude Code" = "如何安装 Claude Code";
+/* korekore UI (ADR-0021) */
+"%lld done · %lld asks today" = "今天完成 %lld 个 · 提问 %lld 次";
+"DAEMON NOT RUNNING" = "daemon 没在运行";
+"BOX ONLINE · %@" = "盒子在线 · %@";
+"NO BOX LINKED" = "没有连上盒子";
+"app %@ · box fw %@" = "App %@ · 盒子固件 %@";
+"app %@" = "App %@";
+"This page comes with the next step of korekore." = "这一页会在 korekore 的下一步做好。";
+"%@ done" = "完成 %@";
+"%@ asks" = "提问 %@";
+"%@ busy" = "忙碌 %@";
+"1 AGENT" = "1 个 Agent";
+"%lld AGENTS" = "%lld 个 Agent";
+"box online · %@ · fw %@" = "盒子在线 · %@ · 固件 %@";
+"box online · %@" = "盒子在线 · %@";
+"no box linked" = "没有连上盒子";
+"Who lives in the box" = "谁住在盒子里";
+"Who the box watches" = "盒子替你看着谁";
+"Hold K0 on the box and ask" = "按住盒子上的 K0 提问";
+"The box, its firmware and its network" = "盒子、固件和网络";
+"How korekore behaves on this Mac" = "korekore 在这台 Mac 上怎么工作";
+"How korekore behaves on this computer" = "korekore 在这台电脑上怎么工作";
+"For when something's off" = "出问题时来这里";
+"Check for updates…" = "检查更新…";
+"Report a problem…" = "报告问题…";
+"Quit korekore (the box goes offline)" = "退出 korekore(盒子将离线)";
/* Account (ADR-0018) */
"Account" = "账户";
"Sign in" = "登录";
diff --git a/app/Package.swift b/app/Package.swift
index d0794247..6cdd2de6 100644
--- a/app/Package.swift
+++ b/app/Package.swift
@@ -13,9 +13,11 @@ let package = Package(
targets: [
// Pure logic: status decoding, menu state derivation, hook config merging, voice pack parsing. No UI.
.target(name: "VibeBuddyCore", path: "Sources/VibeBuddyCore"),
+ // The korekore UI's views (ADR-0021), fed plain values so KorekorePreview can draw them without the app.
+ .target(name: "KorekoreUI", dependencies: ["VibeBuddyCore"], path: "Sources/KorekoreUI"),
.executableTarget(
name: "VibeBuddy",
- dependencies: ["VibeBuddyCore", .product(name: "Sparkle", package: "Sparkle")],
+ dependencies: ["VibeBuddyCore", "KorekoreUI", .product(name: "Sparkle", package: "Sparkle")],
path: "Sources/VibeBuddy",
// build-app.sh puts Sparkle.framework in Contents/Frameworks.
linkerSettings: [.unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path/../Frameworks"])]
@@ -23,5 +25,8 @@ let package = Package(
// The command line tools have neither XCTest nor swift-testing; the view-model seams are
// guarded by this self-test executable: `swift run SelfTest`.
.executableTarget(name: "SelfTest", dependencies: ["VibeBuddyCore"], path: "Sources/SelfTest"),
+ // Draws the korekore views with sample data into PNGs, to set beside the design canvas:
+ // `swift run --package-path app KorekorePreview
`.
+ .executableTarget(name: "KorekorePreview", dependencies: ["KorekoreUI"], path: "Sources/KorekorePreview"),
]
)
diff --git a/app/Sources/KorekorePreview/main.swift b/app/Sources/KorekorePreview/main.swift
new file mode 100644
index 00000000..e6f3665b
--- /dev/null
+++ b/app/Sources/KorekorePreview/main.swift
@@ -0,0 +1,95 @@
+// Draws the korekore views with the canvas's sample data into PNGs (2×), to set beside the design's boards.
+// `swift run --package-path app KorekorePreview [fonts dir]`; the fonts default to fonts/korekore.
+import AppKit
+import KorekoreUI
+import SwiftUI
+import VibeBuddyCore
+
+let arguments = CommandLine.arguments
+guard arguments.count >= 2 else {
+ print("usage: KorekorePreview [fonts dir]")
+ exit(2)
+}
+let output = URL(fileURLWithPath: arguments[1])
+KorekoreAssets.registerFonts(in: URL(fileURLWithPath: arguments.count > 2 ? arguments[2] : "fonts/korekore"))
+try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
+
+func picture(_ activities: [ActivitySummary], connected: Bool = true, mode: String = "duty") -> BoxPicture {
+ let json = """
+ {"daemon":{"build":"0.5.0"},"device":{"connected":\(connected),"bridge":false,"mode":"\(mode)","firmware_version":"0.5.0"},
+ "today":{"done":7,"asks":3,"busy_seconds":15120},"hooks":{},"config":{"notify_link":true}}
+ """
+ var status = try! StatusCoding.decoder().decode(Status.self, from: Data(json.utf8))
+ status.activities = activities
+ return BoxPicture.derive(status: status, daemonAlive: true)
+}
+
+// The canvas's sidebar sample: three cards, all working.
+let working = picture([
+ ActivitySummary(agent: "claude-code", title: "CC:Add Chinese to the box", project: "vibebuddy", state: "working"),
+ ActivitySummary(agent: "codex", title: "CX:Fix voice reply", project: "vibebuddy", state: "working"),
+ ActivitySummary(agent: "github-actions", title: "CI:App tests", project: "vibebuddy", state: "working"),
+])
+let asking = picture([
+ ActivitySummary(agent: "codex", title: "CX:Review changes", project: "vibebuddy", state: "needs_input"),
+ ActivitySummary(agent: "claude-code", title: "CC:Add Chinese to the box", project: "vibebuddy", state: "working"),
+])
+let quiet = picture([])
+let unlinked = picture([], connected: false)
+let versions = KorekoreVersions(app: "0.5.0", firmware: "0.5.0")
+let noActions = KorekoreMenuActions(settings: {}, checkForUpdates: {}, reportProblem: {}, quit: {}, restartDaemon: {})
+
+@MainActor func save(_ name: String, _ view: V) {
+ let renderer = ImageRenderer(content: view.environment(\.colorScheme, .light))
+ renderer.scale = 2
+ guard let image = renderer.cgImage else {
+ print("could not render \(name)")
+ return
+ }
+ let data = NSBitmapImageRep(cgImage: image).representation(using: .png, properties: [:])!
+ try! data.write(to: output.appendingPathComponent("\(name).png"))
+ print("wrote \(name).png")
+}
+
+/// A board as the canvas frames it: 960 × 820 on the mockup backdrop, the window inset 24.
+@MainActor func window(_ picture: BoxPicture, _ section: KorekoreSection) -> some View {
+ KorekoreSettings(picture: picture, versions: versions, sections: KorekoreSection.allCases,
+ selection: .constant(section)) { _ in KorekorePagePending() }
+ .padding(24)
+ .frame(width: 960, height: 820)
+ .background(KorekoreTheme.Colors.backdrop)
+}
+
+MainActor.assumeIsolated {
+ save("window-character", window(working, .character))
+ save("window-device-asking", window(asking, .device))
+ for (name, sample) in [("working", working), ("asking", asking), ("quiet", quiet), ("no-link", unlinked)] {
+ save("minibox-\(name)", MiniBox(picture: sample).padding(12).background(KorekoreTheme.Colors.paper))
+ }
+ save("menu-panel", KorekoreMenuPanel(picture: working, actions: noActions)
+ .padding(24).background(Color(korekoreRGB: 0xC9C3D8)))
+ save("menu-panel-no-link", KorekoreMenuPanel(picture: unlinked, actions: noActions)
+ .padding(24).background(Color(korekoreRGB: 0xC9C3D8)))
+ save("menu-icons", HStack(spacing: 16) {
+ ForEach(Array([working, asking, unlinked].enumerated()), id: \.offset) { _, sample in
+ Image(nsImage: KorekoreMenuIcon.image(for: sample)).interpolation(.none)
+ }
+ }.padding(8).background(Color.white.opacity(0.75)))
+}
+
+// The real window, system buttons included, drawn off screen: checks the frame and where the buttons land.
+MainActor.assumeIsolated {
+ NSApplication.shared.setActivationPolicy(.prohibited)
+ let window = KorekoreSettingsWindow(rootView: KorekoreSettings(
+ picture: working, versions: versions, sections: KorekoreSection.allCases, selection: .constant(.character)
+ ) { _ in KorekorePagePending() })
+ window.setFrameOrigin(NSPoint(x: -20_000, y: 0))
+ window.orderFrontRegardless()
+ RunLoop.main.run(until: Date().addingTimeInterval(1))
+ let frame = window.contentView!.superview!
+ let bitmap = frame.bitmapImageRepForCachingDisplay(in: frame.bounds)!
+ frame.cacheDisplay(in: frame.bounds, to: bitmap)
+ try! bitmap.representation(using: .png, properties: [:])!.write(to: output.appendingPathComponent("window-real.png"))
+ print("wrote window-real.png")
+ window.close()
+}
diff --git a/app/Sources/KorekoreUI/Components.swift b/app/Sources/KorekoreUI/Components.swift
new file mode 100644
index 00000000..0c987252
--- /dev/null
+++ b/app/Sources/KorekoreUI/Components.swift
@@ -0,0 +1,112 @@
+import SwiftUI
+import VibeBuddyCore
+
+typealias Theme = KorekoreTheme
+
+extension Font {
+ /// A type style from tokens.json.
+ static func korekore(_ style: KorekoreTextStyle) -> Font {
+ .custom(style.font, fixedSize: style.size)
+ }
+
+ /// M PLUS Rounded 1c at a size the type styles don't name (the sidebar's 26 px wordmark, say).
+ static func rounded(_ size: CGFloat, weight: Int = 800) -> Font {
+ let name = switch weight {
+ case ..<550: Theme.FontName.roundedRegular
+ case ..<750: Theme.FontName.roundedBold
+ default: Theme.FontName.roundedExtraBold
+ }
+ return .custom(name, fixedSize: size)
+ }
+
+ /// DotGothic16, for the machine-ish bits: state words, versions, counts.
+ static func pixel(_ size: CGFloat) -> Font {
+ .custom(Theme.FontName.pixelRegular, fixedSize: size)
+ }
+}
+
+/// A sprite from docs/design/korekore at a whole-number scale, every pixel a crisp square.
+struct Sprite: View {
+ let name: String
+ var scale = 1
+ var tint: UInt32?
+ /// Kore asleep without a link: CSS `grayscale(1) brightness(0.75)`, as the canvas draws it.
+ var gray = false
+
+ var body: some View {
+ if let sprite = KorekoreSprites.all[name].map({ gray ? $0.grayed(brightness: 0.75) : $0 }),
+ let image = sprite.image(scale: scale, tint: tint) {
+ Image(nsImage: image)
+ .interpolation(.none)
+ .frame(width: CGFloat(sprite.width * scale), height: CGFloat(sprite.height * scale))
+ }
+ }
+}
+
+/// The "your vibebuddy" bitmap tag: 2 px dots, solid, in `kore-pink-text` beside small wordmarks.
+struct PixelTag: View {
+ var dot = 2
+ var color: UInt32 = 0xC2335B
+
+ var body: some View {
+ Sprite(name: "tag-your-vibebuddy", scale: dot, tint: color)
+ }
+}
+
+/// The sticker look: a fill, an ink outline inside the edge, and a solid ink shadow straight down, no blur.
+struct Sticker: ViewModifier {
+ var radius: CGFloat
+ var fill: Color = Theme.Colors.paper
+ var stroke: CGFloat = Theme.Stroke.base
+ var outline: Color = Theme.Colors.ink
+ var shadow: CGFloat = Theme.Sticker.sm
+
+ func body(content: Content) -> some View {
+ let shape = RoundedRectangle(cornerRadius: radius, style: .continuous)
+ content
+ .background(shape.fill(fill))
+ .clipShape(shape)
+ .overlay(shape.strokeBorder(outline, lineWidth: stroke))
+ .background(shape.fill(Theme.Colors.ink).offset(y: shadow))
+ }
+}
+
+extension View {
+ func sticker(radius: CGFloat, fill: Color = Theme.Colors.paper, stroke: CGFloat = Theme.Stroke.base,
+ outline: Color = Theme.Colors.ink, shadow: CGFloat = Theme.Sticker.sm) -> some View {
+ modifier(Sticker(radius: radius, fill: fill, stroke: stroke, outline: outline, shadow: shadow))
+ }
+}
+
+/// How each state reads on the box: its word, its colour and Kore's face.
+extension BoxPicture.Mood {
+ var word: String {
+ switch self {
+ case .ready: "READY"
+ case .working: "WORKING"
+ case .input: "ASK"
+ case .pomodoro: "POMODORO"
+ case .leisure: "LEISURE"
+ case .noLink: "NO LINK"
+ }
+ }
+
+ var color: Color {
+ switch self {
+ case .ready, .leisure: Theme.Colors.lavender
+ case .working: Theme.Colors.sky
+ case .input: Theme.Colors.sun
+ case .pomodoro: Theme.Colors.korePink
+ case .noLink: Theme.Colors.mist
+ }
+ }
+
+ var expression: String {
+ switch self {
+ case .ready, .pomodoro, .leisure: "kore-normal"
+ case .working: "kore-working"
+ case .input: "kore-input"
+ case .noLink: "kore-sleep"
+ }
+ }
+}
diff --git a/app/Sources/VibeBuddy/Korekore/KorekoreAssets.swift b/app/Sources/KorekoreUI/KorekoreAssets.swift
similarity index 79%
rename from app/Sources/VibeBuddy/Korekore/KorekoreAssets.swift
rename to app/Sources/KorekoreUI/KorekoreAssets.swift
index c9863e56..5108eefb 100644
--- a/app/Sources/VibeBuddy/Korekore/KorekoreAssets.swift
+++ b/app/Sources/KorekoreUI/KorekoreAssets.swift
@@ -3,11 +3,10 @@ import CoreText
import VibeBuddyCore
/// The korekore UI's fonts and sprite images (ADR-0021).
-enum KorekoreAssets {
- /// Makes the bundled fonts (Resources/fonts, from tools/korekore-fonts.py) available to this process only.
- /// Called at launch when the korekore UI is on; the current UI keeps the system font.
- static func registerFonts() {
- let directory = Resources.resourcesDirectory.appendingPathComponent("fonts")
+public enum KorekoreAssets {
+ /// Makes the fonts in `directory` (the bundle's Resources/fonts, from tools/korekore-fonts.py) available to this
+ /// process only. Called at launch when the korekore UI is on; the current UI keeps the system font.
+ public static func registerFonts(in directory: URL) {
let fonts = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? []
for font in fonts where font.pathExtension == "ttf" {
var error: Unmanaged?
@@ -21,7 +20,7 @@ enum KorekoreAssets {
extension PixelSprite {
/// The sprite at `scale` whole times its size, one image pixel per screen point at 1×. Draw it with
/// `.interpolation(.none)` and at exactly this size, so nothing resamples it.
- func image(scale: Int, tint: UInt32? = nil) -> NSImage? {
+ public func image(scale: Int, tint: UInt32? = nil) -> NSImage? {
let (outWidth, outHeight) = (width * scale, height * scale)
let data = Data(rgba(scale: scale, tint: tint)) as CFData
guard let provider = CGDataProvider(data: data),
diff --git a/app/Sources/KorekoreUI/KorekoreTheme.swift b/app/Sources/KorekoreUI/KorekoreTheme.swift
new file mode 100644
index 00000000..0051a381
--- /dev/null
+++ b/app/Sources/KorekoreUI/KorekoreTheme.swift
@@ -0,0 +1,193 @@
+// Generated by tools/korekore-codegen.py from docs/design/korekore. Do not edit.
+import SwiftUI
+
+/// The korekore design tokens (docs/design/korekore/tokens.json).
+public enum KorekoreTheme {
+ public enum Colors {
+ /// Text, every outline (2-3 px), sticker shadows, selected nav row. Also the box's screen ground.
+ public static let ink = Color(korekoreRGB: 0x1C1730)
+ /// App window and brand ground. Text on it: ink, muted, kore-pink-text.
+ public static let cream = Color(korekoreRGB: 0xFFF6E8)
+ /// Cards, list rows, secondary buttons, inputs on cream.
+ public static let paper = Color(korekoreRGB: 0xFFFFFF)
+ /// Behind windows in mockups; the toggle track when off.
+ public static let backdrop = Color(korekoreRGB: 0xE9E4F2)
+ /// Dividers between rows inside a card (2 px). Never an outer border.
+ public static let line = Color(korekoreRGB: 0xF1E7D6)
+ /// Secondary text on cream and paper (about 9:1).
+ public static let muted = Color(korekoreRGB: 0x4A4360)
+ /// Tertiary pixel text: versions, timestamps, footnotes (about 5:1 on cream).
+ public static let muted2 = Color(korekoreRGB: 0x6B6482)
+ /// Brand accent as a FILL: selected character border, progress, toggle on, Connect button, Pomodoro screen. Ink text on it (5.3:1). Never small text on cream.
+ public static let korePink = Color(korekoreRGB: 0xF0587F)
+ /// Pink TEXT on cream or paper below 24 px: the small pixel tag, links, IN USE, kicker lines (5.0:1 on cream, 5.4:1 on paper).
+ public static let korePinkText = Color(korekoreRGB: 0xC2335B)
+ /// The large bitmap tag (3-5 px dots) on cream or paper. Large-text contrast only.
+ public static let korePinkTag = Color(korekoreRGB: 0xE2466F)
+ /// Needs input: the one loud state. Full-screen takeover on the box; primary action fill in the app (ink text).
+ public static let sun = Color(korekoreRGB: 0xFFD23F)
+ /// Working. Calm, silent. RUN pills, the WORKING word, Kore's ... marks.
+ public static let sky = Color(korekoreRGB: 0x7CC8FF)
+ /// Done. 3 s full-screen celebration on the box; done pills.
+ public static let mint = Color(korekoreRGB: 0x6EE7B7)
+ /// Failed. Border and words on Ink, never a full red screen.
+ public static let peach = Color(korekoreRGB: 0xFF7A7A)
+ /// Ready / idle and the ON DUTY label.
+ public static let lavender = Color(korekoreRGB: 0xB9A8FF)
+ /// No link. The whole box screen goes gray; mist is for the big NO LINK word (4.1:1 on box-gray-ground, large text only).
+ public static let mist = Color(korekoreRGB: 0x8A85A3)
+ /// Small text on the no-link screen (6.0:1 on box-gray-ground).
+ public static let mistText = Color(korekoreRGB: 0xA8A3BD)
+ /// Done / watching / joined as TEXT on cream or paper (5.0:1 on cream; mint fails as text).
+ public static let doneText = Color(korekoreRGB: 0x177A56)
+ /// Needs input / waiting as TEXT on cream or paper (5.3:1 on cream; sun fails as text).
+ public static let waitText = Color(korekoreRGB: 0x8A5F00)
+ /// Working / busy as TEXT on cream or paper.
+ public static let workText = Color(korekoreRGB: 0x2E6FB0)
+ /// The box's screen background in calm states.
+ public static let boxGround = Color(korekoreRGB: 0x1C1730)
+ /// Task tickets and the side panel on the box; agent rows on Ink.
+ public static let boxCard = Color(korekoreRGB: 0x2A2345)
+ /// Kore's screen-face; empty segments of meters on the box.
+ public static let boxFace = Color(korekoreRGB: 0x3B2F63)
+ /// Primary text on the box. Also Kore's shell.
+ public static let boxText = Color(korekoreRGB: 0xFFF3DD)
+ /// Project names, times, hints on the box.
+ public static let boxTextMuted = Color(korekoreRGB: 0xA9A2C2)
+ /// Dimmed screen behind the box's MENU card.
+ public static let boxDim = Color(korekoreRGB: 0x0F0C1C)
+ /// The box's ground while there is no link.
+ public static let boxGrayGround = Color(korekoreRGB: 0x2A2833)
+ /// Kore's outline pixels only (sprite palette).
+ public static let koreOutline = Color(korekoreRGB: 0x2B2140)
+ /// Cheeks on Kore and every Character.
+ public static let blush = Color(korekoreRGB: 0xFF9EB8)
+ /// Kore's mouth pixels.
+ public static let koreMouth = Color(korekoreRGB: 0xFFB3C8)
+ /// Claude Code tile.
+ public static let agentCc = Color(korekoreRGB: 0xF2A27E)
+ /// Codex tile.
+ public static let agentCx = Color(korekoreRGB: 0x9DB4FF)
+ /// GitHub Actions tile; General and Advanced nav tiles.
+ public static let agentCi = Color(korekoreRGB: 0xD9D3EA)
+ /// OpenCode tile.
+ public static let agentOc = Color(korekoreRGB: 0x8FE3C4)
+ /// GitHub Copilot CLI tile.
+ public static let agentGh = Color(korekoreRGB: 0xA8D8FF)
+ /// Pi tile.
+ public static let agentPi = Color(korekoreRGB: 0xFFC59E)
+ /// Your own events (POST /v1/events) tile; Conversation nav tile.
+ public static let agentApi = Color(korekoreRGB: 0xFFE58A)
+ }
+
+ /// The bundled fonts (Resources/fonts) by PostScript name.
+ public enum FontName {
+ public static let roundedRegular = "RoundedMplus1c-Regular"
+ public static let roundedBold = "RoundedMplus1c-Bold"
+ public static let roundedExtraBold = "RoundedMplus1c-ExtraBold"
+ public static let pixelRegular = "DotGothic16-Regular"
+ }
+
+ public enum Typography {
+ /// Rounded Mplus 1c 800. The wordmark, large lockups only.
+ public static let display = KorekoreTextStyle(font: "RoundedMplus1c-ExtraBold", size: 104, lineHeight: 93.6, letterSpacing: -3.12)
+ /// Rounded Mplus 1c 800. Page title in the app's content area.
+ public static let title = KorekoreTextStyle(font: "RoundedMplus1c-ExtraBold", size: 28, lineHeight: 34, letterSpacing: 0)
+ /// Rounded Mplus 1c 800. Onboarding headlines.
+ public static let hero = KorekoreTextStyle(font: "RoundedMplus1c-ExtraBold", size: 42, lineHeight: 47, letterSpacing: 0)
+ /// Rounded Mplus 1c 800. Card titles, banner lines.
+ public static let heading = KorekoreTextStyle(font: "RoundedMplus1c-ExtraBold", size: 17, lineHeight: 22, letterSpacing: 0)
+ /// Rounded Mplus 1c 800. Row names, nav items, character names.
+ public static let label = KorekoreTextStyle(font: "RoundedMplus1c-ExtraBold", size: 15, lineHeight: 20, letterSpacing: 0)
+ /// Rounded Mplus 1c 800. Buttons and chips (12 px in dense rows).
+ public static let button = KorekoreTextStyle(font: "RoundedMplus1c-ExtraBold", size: 13, lineHeight: 18, letterSpacing: 0)
+ /// Rounded Mplus 1c 400. Paragraphs, chat bubbles.
+ public static let body = KorekoreTextStyle(font: "RoundedMplus1c-Regular", size: 14, lineHeight: 21, letterSpacing: 0)
+ /// Rounded Mplus 1c 400. Row details, captions.
+ public static let small = KorekoreTextStyle(font: "RoundedMplus1c-Regular", size: 12, lineHeight: 17, letterSpacing: 0)
+ /// DotGothic16 400. The state word on the box (22-36 px on a takeover).
+ public static let boxState = KorekoreTextStyle(font: "DotGothic16-Regular", size: 24, lineHeight: 24, letterSpacing: 0)
+ /// DotGothic16 400. Ticket titles on the box (9-12 px).
+ public static let boxText = KorekoreTextStyle(font: "DotGothic16-Regular", size: 11, lineHeight: 14, letterSpacing: 0)
+ /// DotGothic16 400. Values, versions, ports in the app.
+ public static let pixelValue = KorekoreTextStyle(font: "DotGothic16-Regular", size: 13, lineHeight: 18, letterSpacing: 0)
+ /// DotGothic16 400. State tags in the app: uppercase, state *-text colour, no background.
+ public static let pixelTag = KorekoreTextStyle(font: "DotGothic16-Regular", size: 12, lineHeight: 16, letterSpacing: 0)
+ }
+
+ public enum Space {
+ /// Inside pills, icon-to-text in pixel rows.
+ public static let s1: CGFloat = 4
+ /// Gaps in button rows, between tickets on the box (px x 1.5 there).
+ public static let s2: CGFloat = 8
+ /// Row padding, card grid gaps.
+ public static let s3: CGFloat = 12
+ /// Card padding, content-area gaps.
+ public static let s4: CGFloat = 16
+ /// Window inset, page padding.
+ public static let s5: CGFloat = 24
+ /// Onboarding columns, board margins.
+ public static let s6: CGFloat = 40
+ }
+
+ public enum Radius {
+ /// Key chips (K0 K1 K2) and state pills on the box; pixel tags.
+ public static let pixel: CGFloat = 4
+ /// Inner tiles: sprite stages, inputs, ticket rows on the box (8 px at 1x).
+ public static let sm: CGFloat = 10
+ /// Cards and grouped lists.
+ public static let md: CGFloat = 16
+ /// Windows and panels.
+ public static let lg: CGFloat = 20
+ /// Hero panels (onboarding Kore stage).
+ public static let xl: CGFloat = 28
+ /// Buttons, chips, toggles, tabs.
+ public static let pill: CGFloat = 999
+ }
+
+ public enum Stroke {
+ /// Pixel-tag outlines on the box and status squares.
+ public static let thin: CGFloat = 1.5
+ /// Cards, buttons, toggles, inputs, chips.
+ public static let base: CGFloat = 2
+ /// Windows, the selected character card (kore-pink), primary hero buttons.
+ public static let bold: CGFloat = 3
+ }
+
+ /// Solid ink drop shadows: how far down they fall.
+ public enum Sticker {
+ /// Cards, list groups, the sidebar box mirror. Solid ink, no blur: the sticker look.
+ public static let sm: CGFloat = 4
+ /// Hero panels, lockup cards, the menu bar panel.
+ public static let md: CGFloat = 6
+ /// App windows.
+ public static let lg: CGFloat = 8
+ }
+
+ public enum Pixel {
+ /// Small bitmap tag (sidebar, title bar), solid, no grain.
+ public static let dotSm: CGFloat = 2
+ /// Medium tag on Ink lockups.
+ public static let dotMd: CGFloat = 3
+ /// Large tag: 3 px dot + 1 px grain gap, 1 px corner.
+ public static let dotLg: CGFloat = 4
+ /// Kore and Characters in app cards: 1 sprite pixel = 4 px.
+ public static let spriteScaleApp: Int = 4
+ /// Kore on the 320x240 box screen in takeovers (4 on duty).
+ public static let spriteScaleBox: Int = 5
+ }
+}
+
+/// A type style: the font file, its size, line height and letter spacing, all in points.
+public struct KorekoreTextStyle: Sendable {
+ public let font: String
+ public let size: CGFloat
+ public let lineHeight: CGFloat
+ public let letterSpacing: CGFloat
+}
+
+extension Color {
+ public init(korekoreRGB rgb: UInt32) {
+ self.init(.sRGB, red: Double(rgb >> 16 & 0xFF) / 255, green: Double(rgb >> 8 & 0xFF) / 255, blue: Double(rgb & 0xFF) / 255)
+ }
+}
diff --git a/app/Sources/KorekoreUI/MenuPanel.swift b/app/Sources/KorekoreUI/MenuPanel.swift
new file mode 100644
index 00000000..258507d7
--- /dev/null
+++ b/app/Sources/KorekoreUI/MenuPanel.swift
@@ -0,0 +1,200 @@
+import AppKit
+import SwiftUI
+import VibeBuddyCore
+
+/// What the menu bar panel's rows do; the app fills these in.
+public struct KorekoreMenuActions {
+ public var settings: () -> Void
+ public var checkForUpdates: () -> Void
+ public var reportProblem: () -> Void
+ public var quit: () -> Void
+ /// The daemon isn't answering: start it again.
+ public var restartDaemon: () -> Void
+
+ public init(settings: @escaping () -> Void, checkForUpdates: @escaping () -> Void, reportProblem: @escaping () -> Void,
+ quit: @escaping () -> Void, restartDaemon: @escaping () -> Void) {
+ self.settings = settings
+ self.checkForUpdates = checkForUpdates
+ self.reportProblem = reportProblem
+ self.quit = quit
+ self.restartDaemon = restartDaemon
+ }
+}
+
+/// The panel the menu bar item opens (canvas: "Mac · Menu bar"), 360 px wide.
+public struct KorekoreMenuPanel: View {
+ let picture: BoxPicture
+ let actions: KorekoreMenuActions
+ @State private var hovered: Int?
+
+ public init(picture: BoxPicture, actions: KorekoreMenuActions) {
+ self.picture = picture
+ self.actions = actions
+ }
+
+ public static let width: CGFloat = 360
+
+ public var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .bottom, spacing: 8) {
+ Text(verbatim: "korekore").font(.rounded(18))
+ PixelTag()
+ }
+ strip
+ modes
+ stats
+ items
+ }
+ .foregroundStyle(Theme.Colors.ink)
+ .padding(14)
+ .frame(width: Self.width - 2 * Theme.Stroke.bold, alignment: .leading)
+ .sticker(radius: 18, fill: Theme.Colors.cream, stroke: Theme.Stroke.bold, shadow: Theme.Sticker.md)
+ .padding(.bottom, Theme.Sticker.md)
+ }
+
+ /// The box at a glance, on Ink like the box itself. Without the daemon it offers to start it again.
+ private var strip: some View {
+ let mood = picture.mood
+ return HStack(spacing: 12) {
+ Sprite(name: mood.expression, scale: 3, gray: mood == .noLink)
+ VStack(alignment: .leading, spacing: 3) {
+ stripTitle.font(.pixel(16)).foregroundStyle(mood.color)
+ stripDetail.font(.pixel(12)).foregroundStyle(Theme.Colors.boxTextMuted)
+ }
+ Spacer(minLength: 0)
+ }
+ .padding(10)
+ .background(Theme.Colors.ink, in: RoundedRectangle(cornerRadius: 12))
+ .contentShape(Rectangle())
+ .onTapGesture { if !picture.daemonAlive { actions.restartDaemon() } }
+ }
+
+ @ViewBuilder private var stripTitle: some View {
+ let word = picture.mood.word
+ switch picture.mood {
+ case .working, .input:
+ if picture.activityCount == 1 {
+ Text(verbatim: "\(word) · ") + Text("1 AGENT")
+ } else {
+ Text(verbatim: "\(word) · ") + Text("\(picture.activityCount) AGENTS")
+ }
+ default:
+ Text(verbatim: word)
+ }
+ }
+
+ @ViewBuilder private var stripDetail: some View {
+ if !picture.daemonAlive {
+ Text("daemon not responding · click to restart")
+ } else if let link = picture.link {
+ if let firmware = picture.firmwareVersion {
+ Text("box online · \(link.rawValue) · fw \(firmware)")
+ } else {
+ Text("box online · \(link.rawValue)")
+ }
+ } else {
+ Text("no box linked")
+ }
+ }
+
+ /// The box's mode, as chips; the box changes it (K0), so here they only show it.
+ private var modes: some View {
+ HStack(spacing: 6) {
+ // Without a link the last mode heard may be stale, so none is lit.
+ chip(String(localized: "On duty"), on: picture.link != nil && picture.mode == "duty")
+ chip(String(localized: "Pomodoro"), on: picture.link != nil && picture.mode == "pomodoro")
+ chip(String(localized: "Leisure"), on: picture.link != nil && picture.mode == "leisure")
+ }
+ }
+
+ private func chip(_ label: String, on: Bool) -> some View {
+ Text(verbatim: label)
+ .font(.rounded(12))
+ .padding(.vertical, 3)
+ .padding(.horizontal, 10)
+ .foregroundStyle(on ? Theme.Colors.cream : Theme.Colors.ink)
+ .background(on ? Theme.Colors.ink : Theme.Colors.paper, in: Capsule())
+ .overlay(Capsule().strokeBorder(Theme.Colors.ink, lineWidth: Theme.Stroke.base))
+ }
+
+ private var stats: some View {
+ HStack(spacing: 8) {
+ stat(Text("\(Text(verbatim: "\(picture.today.done)").foregroundColor(Theme.Colors.doneText)) done"))
+ stat(Text("\(Text(verbatim: "\(picture.today.asks)").foregroundColor(Theme.Colors.waitText)) asks"))
+ stat(Text("\(Text(verbatim: BoxPicture.busy(picture.today.busySeconds)).foregroundColor(Theme.Colors.workText)) busy"))
+ }
+ .font(.pixel(13))
+ }
+
+ private func stat(_ label: Text) -> some View {
+ label
+ .lineLimit(1)
+ .padding(6)
+ .frame(maxWidth: .infinity)
+ .background(Theme.Colors.paper, in: RoundedRectangle(cornerRadius: 10))
+ .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(Theme.Colors.ink, lineWidth: Theme.Stroke.base))
+ }
+
+ private var items: some View {
+ VStack(spacing: 0) {
+ item(0, String(localized: "Settings…"), key: "⌘,", action: actions.settings)
+ item(1, String(localized: "Check for updates…"), action: actions.checkForUpdates)
+ item(2, String(localized: "Report a problem…"), action: actions.reportProblem)
+ item(3, String(localized: "Quit korekore (the box goes offline)"), key: "⌘Q", action: actions.quit)
+ }
+ .padding(.top, 6)
+ .overlay(alignment: .top) { Rectangle().fill(Theme.Colors.line).frame(height: Theme.Stroke.base) }
+ }
+
+ private func item(_ index: Int, _ label: String, key: String = "", action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ HStack {
+ Text(verbatim: label).font(.rounded(14, weight: 700))
+ Spacer()
+ Text(verbatim: key).font(.pixel(12)).foregroundStyle(Theme.Colors.muted2)
+ }
+ .padding(.vertical, 7)
+ .padding(.horizontal, 8)
+ .background(hovered == index ? Theme.Colors.paper : .clear, in: RoundedRectangle(cornerRadius: 10))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .onHover { hovered = $0 ? index : (hovered == index ? nil : hovered) }
+ }
+}
+
+/// The menu bar item: Kore at 1×, in colour online, with a `sun` dot when an agent needs you, gray and asleep
+/// without a link (canvas: "Mac · Menu bar", bottom row).
+public enum KorekoreMenuIcon {
+ public static func image(for picture: BoxPicture) -> NSImage {
+ let mood = picture.mood
+ let needsYou = picture.tickets.contains(where: \.needsInput)
+ let awake = KorekoreSprites.all[needsYou ? "kore-input" : "kore-normal"]!
+ let kore = mood == .noLink ? KorekoreSprites.koreSleep.grayed(brightness: 0.75) : awake
+ // Two image pixels per point, so each of Kore's pixels is one point, sharp on Retina.
+ let pixels = kore.rgba(scale: 2)
+ let dot: CGFloat = needsYou ? 7 + 4 : 0
+ let size = NSSize(width: CGFloat(kore.width) + dot, height: CGFloat(kore.height))
+ let image = NSImage(size: size, flipped: false) { _ in
+ guard let context = NSGraphicsContext.current?.cgContext,
+ let provider = CGDataProvider(data: Data(pixels) as CFData),
+ let sprite = CGImage(width: kore.width * 2, height: kore.height * 2, bitsPerComponent: 8, bitsPerPixel: 32,
+ bytesPerRow: kore.width * 8, space: CGColorSpace(name: CGColorSpace.sRGB)!,
+ bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue), provider: provider,
+ decode: nil, shouldInterpolate: false, intent: .defaultIntent)
+ else { return false }
+ context.interpolationQuality = .none
+ context.draw(sprite, in: CGRect(x: 0, y: 0, width: kore.width, height: kore.height))
+ if needsYou {
+ let rect = CGRect(x: CGFloat(kore.width) + 4 + 0.75, y: (CGFloat(kore.height) - 7) / 2 + 0.75, width: 5.5, height: 5.5)
+ context.setFillColor(NSColor(Theme.Colors.sun).cgColor)
+ context.fillEllipse(in: rect)
+ context.setStrokeColor(NSColor(Theme.Colors.ink).cgColor)
+ context.setLineWidth(1.5)
+ context.strokeEllipse(in: rect)
+ }
+ return true
+ }
+ return image
+ }
+}
diff --git a/app/Sources/KorekoreUI/MiniBox.swift b/app/Sources/KorekoreUI/MiniBox.swift
new file mode 100644
index 00000000..8ae3e4be
--- /dev/null
+++ b/app/Sources/KorekoreUI/MiniBox.swift
@@ -0,0 +1,88 @@
+import SwiftUI
+import VibeBuddyCore
+
+/// The box's screen redrawn at the head of the sidebar (canvas: "App sidebar component"): laid out at the box's
+/// 320 × 240 and shown at 0.65, 208 × 156, as the design does.
+public struct MiniBox: View {
+ let picture: BoxPicture
+
+ public init(picture: BoxPicture) {
+ self.picture = picture
+ }
+
+ static let scale: CGFloat = 0.65
+
+ public var body: some View {
+ screen
+ .scaleEffect(Self.scale, anchor: .topLeading)
+ .frame(width: 320 * Self.scale, height: 240 * Self.scale, alignment: .topLeading)
+ // The outline sits outside the screen, as the canvas's content-box does.
+ .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.md - Theme.Stroke.bold, style: .continuous))
+ .padding(Theme.Stroke.bold)
+ .sticker(radius: Theme.Radius.md, fill: Theme.Colors.ink, stroke: Theme.Stroke.bold)
+ }
+
+ @ViewBuilder private var screen: some View {
+ if picture.mood == .noLink {
+ asleep
+ } else {
+ onDuty
+ }
+ }
+
+ /// No link: the whole screen gray, Kore asleep and grayed at scale 6 (README, "No link").
+ private var asleep: some View {
+ VStack(spacing: 6) {
+ Text(verbatim: "Z z").font(.pixel(14)).foregroundStyle(Theme.Colors.mist)
+ Sprite(name: "kore-sleep", scale: 6, gray: true)
+ Text(verbatim: "NO LINK").font(.korekore(Theme.Typography.boxState)).foregroundStyle(Theme.Colors.mist)
+ Text(verbatim: "waiting for the Mac").font(.pixel(11)).foregroundStyle(Theme.Colors.mistText)
+ }
+ .frame(width: 320, height: 240)
+ .background(Theme.Colors.boxGrayGround)
+ }
+
+ private var onDuty: some View {
+ let mood = picture.mood
+ return HStack(spacing: 0) {
+ VStack(spacing: 8) {
+ Sprite(name: mood.expression, scale: 4)
+ Text(verbatim: mood.word).font(.pixel(16)).foregroundStyle(mood.color)
+ }
+ .frame(width: 96, height: 240)
+ .background(Theme.Colors.boxCard)
+ VStack(alignment: .leading, spacing: 8) {
+ if picture.tickets.isEmpty {
+ quiet
+ } else {
+ ForEach(Array(picture.tickets.enumerated()), id: \.offset) { _, ticket in
+ HStack(spacing: 8) {
+ Sprite(name: "icon-\(ticket.icon)", scale: 2)
+ Text(verbatim: ticket.title).font(.pixel(13)).lineLimit(1)
+ Spacer(minLength: 0)
+ }
+ .padding(7)
+ .background(Theme.Colors.boxCard, in: RoundedRectangle(cornerRadius: 8))
+ }
+ Spacer(minLength: 0)
+ }
+ }
+ .padding(.vertical, 10)
+ .padding(.horizontal, 8)
+ }
+ .foregroundStyle(Theme.Colors.boxText)
+ .frame(width: 320, height: 240)
+ .background(Theme.Colors.boxGround)
+ }
+
+ /// No cards: the box's ALL QUIET with today's numbers.
+ private var quiet: some View {
+ VStack(spacing: 6) {
+ Text(verbatim: "ALL QUIET").font(.pixel(12))
+ Text(verbatim: "\(picture.today.done) done · \(picture.today.asks) asks · \(BoxPicture.busy(picture.today.busySeconds).uppercased()) busy")
+ .font(.pixel(10))
+ .foregroundStyle(Theme.Colors.boxTextMuted)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
diff --git a/app/Sources/KorekoreUI/Settings.swift b/app/Sources/KorekoreUI/Settings.swift
new file mode 100644
index 00000000..963d6253
--- /dev/null
+++ b/app/Sources/KorekoreUI/Settings.swift
@@ -0,0 +1,206 @@
+import SwiftUI
+import VibeBuddyCore
+
+/// The settings window's sections, in the sidebar's order (docs/design/korekore/README.md).
+public enum KorekoreSection: String, CaseIterable, Identifiable, Sendable {
+ case character, agents, conversation, device, general, advanced
+
+ public var id: String { rawValue }
+
+ var title: String {
+ switch self {
+ case .character: String(localized: "Character")
+ case .agents: String(localized: "Agents")
+ case .conversation: String(localized: "Conversation")
+ case .device: String(localized: "Device")
+ case .general: String(localized: "General")
+ case .advanced: String(localized: "Advanced")
+ }
+ }
+
+ var subtitle: String {
+ switch self {
+ case .character: String(localized: "Who lives in the box")
+ case .agents: String(localized: "Who the box watches")
+ case .conversation: String(localized: "Hold K0 on the box and ask")
+ case .device: String(localized: "The box, its firmware and its network")
+ case .general: String(localized: "How korekore behaves on this Mac")
+ case .advanced: String(localized: "For when something's off")
+ }
+ }
+
+ var tile: String {
+ switch self {
+ case .character: "icon-nav-char"
+ case .agents: "icon-nav-agents"
+ case .conversation: "icon-nav-chat"
+ case .device: "icon-nav-box"
+ case .general: "icon-nav-gen"
+ case .advanced: "icon-nav-adv"
+ }
+ }
+}
+
+/// Versions for the foot of the sidebar.
+public struct KorekoreVersions: Equatable, Sendable {
+ public var app: String
+ public var firmware: String?
+
+ public init(app: String, firmware: String?) {
+ self.app = app
+ self.firmware = firmware
+ }
+}
+
+/// The left side of the settings window (canvas: "App sidebar component").
+struct KorekoreSidebar: View {
+ let picture: BoxPicture
+ let versions: KorekoreVersions
+ let sections: [KorekoreSection]
+ @Binding var selection: KorekoreSection
+
+ /// The real window buttons sit here; the window moves them into this row.
+ static let windowButtonsRow: CGFloat = 12
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 14) {
+ Color.clear.frame(height: Self.windowButtonsRow)
+ VStack(alignment: .leading, spacing: 2) {
+ Text(verbatim: "korekore").font(.rounded(26)).tracking(-0.52).foregroundStyle(Theme.Colors.ink)
+ PixelTag().padding(.top, 4)
+ }
+ .padding(.horizontal, 2)
+ MiniBox(picture: picture).frame(maxWidth: .infinity)
+ status
+ VStack(spacing: 4) {
+ ForEach(sections) { section in
+ row(section)
+ }
+ }
+ Spacer(minLength: 0)
+ footer
+ }
+ .padding(EdgeInsets(top: 14, leading: 14, bottom: 16, trailing: 14))
+ .frame(width: 240 - Theme.Stroke.bold, alignment: .leading)
+ .frame(maxHeight: .infinity)
+ .background(Theme.Colors.paper)
+ .overlay(alignment: .trailing) {
+ Rectangle().fill(Theme.Colors.ink).frame(width: Theme.Stroke.bold).offset(x: Theme.Stroke.bold)
+ }
+ .padding(.trailing, Theme.Stroke.bold)
+ }
+
+ private var status: some View {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 6) {
+ Rectangle()
+ .fill(picture.link == nil ? Theme.Colors.mist : Theme.Colors.mint)
+ .frame(width: 8, height: 8)
+ .overlay(Rectangle().strokeBorder(Theme.Colors.ink, lineWidth: Theme.Stroke.thin))
+ linkLine
+ }
+ .foregroundStyle(Theme.Colors.ink)
+ Text("\(picture.today.done) done · \(picture.today.asks) asks today").foregroundStyle(Theme.Colors.muted)
+ }
+ .font(.pixel(12))
+ .padding(.horizontal, 4)
+ }
+
+ @ViewBuilder private var linkLine: some View {
+ if !picture.daemonAlive {
+ Text("DAEMON NOT RUNNING")
+ } else if let link = picture.link {
+ Text("BOX ONLINE · \(link.rawValue)")
+ } else {
+ Text("NO BOX LINKED")
+ }
+ }
+
+ private func row(_ section: KorekoreSection) -> some View {
+ let current = section == selection
+ return Button {
+ selection = section
+ } label: {
+ HStack(spacing: 10) {
+ Sprite(name: section.tile, scale: 2)
+ Text(verbatim: section.title).font(.rounded(15))
+ }
+ .padding(.vertical, 6)
+ .padding(.horizontal, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .foregroundStyle(current ? Theme.Colors.cream : Theme.Colors.ink)
+ .background(current ? Theme.Colors.ink : .clear, in: RoundedRectangle(cornerRadius: 12))
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .accessibilityAddTraits(current ? .isSelected : [])
+ }
+
+ private var footer: some View {
+ Group {
+ if let firmware = versions.firmware {
+ Text("app \(versions.app) · box fw \(firmware)")
+ } else {
+ Text("app \(versions.app)")
+ }
+ }
+ .font(.pixel(12))
+ .foregroundStyle(Theme.Colors.muted2)
+ .padding(.horizontal, 4)
+ }
+}
+
+/// The whole settings window (canvas: "Mac · Character (sidebar)" and its siblings): cream, a 3 px ink outline,
+/// radius 20 and the large sticker shadow, with the sidebar on the left and the section's page on the right.
+public struct KorekoreSettings: View {
+ let picture: BoxPicture
+ let versions: KorekoreVersions
+ let sections: [KorekoreSection]
+ @Binding var selection: KorekoreSection
+ let page: (KorekoreSection) -> Page
+
+ public init(picture: BoxPicture, versions: KorekoreVersions, sections: [KorekoreSection],
+ selection: Binding, @ViewBuilder page: @escaping (KorekoreSection) -> Page) {
+ self.picture = picture
+ self.versions = versions
+ self.sections = sections
+ _selection = selection
+ self.page = page
+ }
+
+ /// Room below the window's outline for its shadow.
+ public static var shadow: CGFloat { Theme.Sticker.lg }
+
+ public var body: some View {
+ HStack(spacing: 0) {
+ KorekoreSidebar(picture: picture, versions: versions, sections: sections, selection: $selection)
+ VStack(alignment: .leading, spacing: 14) {
+ HStack(alignment: .firstTextBaseline) {
+ Text(verbatim: selection.title).font(.korekore(Theme.Typography.title))
+ Spacer()
+ Text(verbatim: selection.subtitle).font(.rounded(13, weight: 400)).foregroundStyle(Theme.Colors.muted)
+ }
+ page(selection)
+ Spacer(minLength: 0)
+ }
+ .foregroundStyle(Theme.Colors.ink)
+ .padding(EdgeInsets(top: 22, leading: 22, bottom: 20, trailing: 22))
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ }
+ .sticker(radius: Theme.Radius.lg, fill: Theme.Colors.cream, stroke: Theme.Stroke.bold, shadow: Self.shadow)
+ .padding(.bottom, Self.shadow)
+ }
+}
+
+/// Stands in for a page that comes with a later step of the korekore UI.
+public struct KorekorePagePending: View {
+ public init() {}
+
+ public var body: some View {
+ Text("This page comes with the next step of korekore.")
+ .font(.rounded(14, weight: 400))
+ .foregroundStyle(Theme.Colors.muted)
+ .frame(maxWidth: .infinity, minHeight: 120)
+ .overlay(RoundedRectangle(cornerRadius: Theme.Radius.md).strokeBorder(Theme.Colors.ink, style: StrokeStyle(lineWidth: Theme.Stroke.base, dash: [6, 4])))
+ }
+}
diff --git a/app/Sources/KorekoreUI/SettingsWindow.swift b/app/Sources/KorekoreUI/SettingsWindow.swift
new file mode 100644
index 00000000..575b7ea1
--- /dev/null
+++ b/app/Sources/KorekoreUI/SettingsWindow.swift
@@ -0,0 +1,57 @@
+import AppKit
+import SwiftUI
+
+/// The korekore settings window: no system frame, so the view draws the design's own (cream, 3 px ink outline,
+/// radius 20, solid shadow); the real window buttons move into the sidebar's top row.
+public final class KorekoreSettingsWindow: NSWindow {
+ /// Where the design puts the first button: the sidebar's padding inside the window's outline.
+ private static let buttonsInset = NSPoint(x: 17, y: 17)
+ private var resizeObserver: Any?
+
+ public init(rootView: Root) {
+ super.init(contentRect: NSRect(x: 0, y: 0, width: 960, height: 772 + KorekoreSettings.shadow),
+ styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
+ backing: .buffered, defer: false)
+ title = "korekore"
+ titleVisibility = .hidden
+ titlebarAppearsTransparent = true
+ isOpaque = false
+ backgroundColor = .clear
+ // The design's shadow is drawn by the view; the system's would trace the transparent corners.
+ hasShadow = false
+ isMovableByWindowBackground = true
+ let hosting = NSHostingView(rootView: rootView.frame(minWidth: 900, minHeight: 760 + KorekoreSettings.shadow)
+ .ignoresSafeArea())
+ hosting.sizingOptions = [.minSize]
+ // The view runs up under the (invisible) title bar: the design's window has none.
+ hosting.safeAreaRegions = []
+ contentView = hosting
+ resizeObserver = NotificationCenter.default.addObserver(forName: NSWindow.didResizeNotification, object: self, queue: .main) { [weak self] _ in
+ MainActor.assumeIsolated { self?.placeButtons() }
+ }
+ placeButtons()
+ }
+
+ override public func layoutIfNeeded() {
+ super.layoutIfNeeded()
+ placeButtons()
+ }
+
+ /// AppKit lays the buttons out again on resize and on key changes, so this runs after each.
+ private func placeButtons() {
+ let buttons = [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton].compactMap { standardWindowButton($0) }
+ guard let container = buttons.first?.superview?.superview else { return }
+ // The title bar is only 28 pt tall; make room for the design's row, 17 pt in from the outline.
+ let height = Self.buttonsInset.y * 2 + (buttons.first?.frame.height ?? 14)
+ if container.frame.height != height {
+ container.frame = NSRect(x: 0, y: frame.height - height, width: frame.width, height: height)
+ }
+ for (index, button) in buttons.enumerated() {
+ let superview = button.superview!
+ superview.frame = container.bounds
+ button.setFrameOrigin(NSPoint(x: Self.buttonsInset.x + CGFloat(index) * (button.frame.width + 6),
+ y: superview.bounds.height - Self.buttonsInset.y - button.frame.height))
+ }
+ }
+}
+
diff --git a/app/Sources/SelfTest/main.swift b/app/Sources/SelfTest/main.swift
index e11ba03d..08368712 100644
--- a/app/Sources/SelfTest/main.swift
+++ b/app/Sources/SelfTest/main.swift
@@ -449,6 +449,30 @@ do {
check(KorekoreSprites.all.values.allSatisfy { s in s.pixels.count == s.width * s.height && s.pixels.allSatisfy { Int($0) <= s.palette.count } }, "every sprite is whole")
}
+// 8. korekore's picture of the box (ADR-0021), drawn from the status's Activities.
+do {
+ let json = #"""
+ {"daemon":{"build":"x"},"device":{"connected":true,"bridge":false,"mode":"duty","network":true},
+ "today":{"done":7,"asks":3,"busy_seconds":15120},"hooks":{},"config":{"notify_link":true},
+ "activities":[{"agent":"codex","title":"CX:Review changes","project":"vibebuddy","state":"needs_input","host":"app","state_for_s":3,"last_event_s":3},
+ {"agent":"claude-code","title":"CC:Add Chinese","state":"working","host":"none","state_for_s":9,"last_event_s":9},
+ {"agent":"github-actions","title":"CI:App tests","state":"working","host":"github","state_for_s":1,"last_event_s":1},
+ {"agent":"pi","title":"PI:Older","state":"working","host":"none","state_for_s":1,"last_event_s":1}]}
+ """#
+ let status = try StatusCoding.decoder().decode(Status.self, from: Data(json.utf8))
+ check(status.activities?.count == 4, "activities decode")
+ let picture = BoxPicture.derive(status: status, daemonAlive: true)
+ check(picture.mood == .input && picture.link == .lan, "an Activity waiting on you makes the box ask; linked over the network")
+ check(picture.tickets.map(\.icon) == ["cx", "cc", "ci"] && picture.tickets.first?.title == "Review changes", "the newest three, by tile, without prefix")
+ check(BoxPicture.derive(status: status, daemonAlive: false).mood == .noLink, "no daemon, no link")
+ check(BoxPicture.cardTitle("Fix: the build") == "Fix: the build", "only a short capital prefix is dropped")
+ check(BoxPicture.busy(15120) == "4h12m" && BoxPicture.busy(2100) == "35m", "busy time reads 4h12m")
+ let old = try StatusCoding.decoder().decode(Status.self, from: Data(sample.utf8))
+ check(old.activities == nil && BoxPicture.derive(status: old, daemonAlive: true).mood == .pomodoro, "an older daemon without activities still decodes; its box is in a Pomodoro")
+} catch {
+ check(false, "korekore status fails to decode: \(error)")
+}
+
if failures > 0 {
print("\(failures) failure(s)")
exit(1)
diff --git a/app/Sources/VibeBuddy/AppDelegate.swift b/app/Sources/VibeBuddy/AppDelegate.swift
index d8352059..da191b9b 100644
--- a/app/Sources/VibeBuddy/AppDelegate.swift
+++ b/app/Sources/VibeBuddy/AppDelegate.swift
@@ -1,5 +1,6 @@
import AppKit
import Combine
+import KorekoreUI
import SwiftUI
import VibeBuddyCore
@@ -7,6 +8,8 @@ import VibeBuddyCore
final class AppDelegate: NSObject, NSApplicationDelegate {
private let model = AppModel()
private var statusItem: NSStatusItem!
+ /// The korekore UI's menu bar item and panel, in place of `statusItem` (ADR-0021).
+ private var korekoreItem: KorekoreStatusItem?
private var settingsWindow: NSWindow?
private var onboardingWindow: NSWindow?
private var subscriptions: Set = []
@@ -20,7 +23,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// An installed copy with the old file name renames itself (or gives way to a new copy) and starts again.
if BundleRename.migrateIfNeeded() { exit(0) }
// The korekore UI's fonts, only when it was asked for (ADR-0021): the current UI keeps the system's.
- if AppUI.atLaunch() == .korekore { KorekoreAssets.registerFonts() }
+ if AppUI.atLaunch() == .korekore { KorekoreAssets.registerFonts(in: Resources.resourcesDirectory.appendingPathComponent("fonts")) }
DistributedNotificationCenter.default().addObserver(forName: SingleInstance.showSettings, object: nil, queue: .main) { [weak self] _ in
MainActor.assumeIsolated { self?.showMainWindow() }
}
@@ -34,10 +37,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
source.resume()
terminationSignal = source
- statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
- statusItem.button?.image = PixelFace.image(eyesClosed: true)
- statusItem.menu = NSMenu()
- model.$menu.receive(on: RunLoop.main).sink { [weak self] menu in self?.render(menu) }.store(in: &subscriptions)
+ if AppUI.atLaunch() == .korekore {
+ korekoreItem = KorekoreStatusItem(model: model, actions: KorekoreMenuActions(
+ settings: { [weak self] in self?.showSettings() }, checkForUpdates: { [weak self] in self?.checkForUpdates() },
+ reportProblem: { [weak self] in self?.reportProblem() }, quit: { [weak self] in self?.quit() },
+ restartDaemon: { [weak self] in self?.restartDaemon() }))
+ } else {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
+ statusItem.button?.image = PixelFace.image(eyesClosed: true)
+ statusItem.menu = NSMenu()
+ model.$menu.receive(on: RunLoop.main).sink { [weak self] menu in self?.render(menu) }.store(in: &subscriptions)
+ }
model.$lastError.receive(on: RunLoop.main).compactMap { $0 }.sink { message in
let alert = NSAlert()
alert.messageText = "VibeBuddy"
@@ -159,6 +169,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func reportProblem() { model.reportProblem() }
@objc func showSettings() {
+ if settingsWindow == nil, AppUI.atLaunch() == .korekore {
+ let window = KorekoreSettingsWindow(rootView: KorekoreSettingsRoot(model: model))
+ window.center()
+ window.isReleasedWhenClosed = false
+ watchClose(window)
+ settingsWindow = window
+ }
if settingsWindow == nil {
// Roomy enough for the whole Character tab at first sight, and resizable down to the old size.
let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 760, height: 640), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
diff --git a/app/Sources/VibeBuddy/Korekore/KorekoreTheme.swift b/app/Sources/VibeBuddy/Korekore/KorekoreTheme.swift
deleted file mode 100644
index 03bea5c3..00000000
--- a/app/Sources/VibeBuddy/Korekore/KorekoreTheme.swift
+++ /dev/null
@@ -1,193 +0,0 @@
-// Generated by tools/korekore-codegen.py from docs/design/korekore. Do not edit.
-import SwiftUI
-
-/// The korekore design tokens (docs/design/korekore/tokens.json).
-enum KorekoreTheme {
- enum Colors {
- /// Text, every outline (2-3 px), sticker shadows, selected nav row. Also the box's screen ground.
- static let ink = Color(korekoreRGB: 0x1C1730)
- /// App window and brand ground. Text on it: ink, muted, kore-pink-text.
- static let cream = Color(korekoreRGB: 0xFFF6E8)
- /// Cards, list rows, secondary buttons, inputs on cream.
- static let paper = Color(korekoreRGB: 0xFFFFFF)
- /// Behind windows in mockups; the toggle track when off.
- static let backdrop = Color(korekoreRGB: 0xE9E4F2)
- /// Dividers between rows inside a card (2 px). Never an outer border.
- static let line = Color(korekoreRGB: 0xF1E7D6)
- /// Secondary text on cream and paper (about 9:1).
- static let muted = Color(korekoreRGB: 0x4A4360)
- /// Tertiary pixel text: versions, timestamps, footnotes (about 5:1 on cream).
- static let muted2 = Color(korekoreRGB: 0x6B6482)
- /// Brand accent as a FILL: selected character border, progress, toggle on, Connect button, Pomodoro screen. Ink text on it (5.3:1). Never small text on cream.
- static let korePink = Color(korekoreRGB: 0xF0587F)
- /// Pink TEXT on cream or paper below 24 px: the small pixel tag, links, IN USE, kicker lines (5.0:1 on cream, 5.4:1 on paper).
- static let korePinkText = Color(korekoreRGB: 0xC2335B)
- /// The large bitmap tag (3-5 px dots) on cream or paper. Large-text contrast only.
- static let korePinkTag = Color(korekoreRGB: 0xE2466F)
- /// Needs input: the one loud state. Full-screen takeover on the box; primary action fill in the app (ink text).
- static let sun = Color(korekoreRGB: 0xFFD23F)
- /// Working. Calm, silent. RUN pills, the WORKING word, Kore's ... marks.
- static let sky = Color(korekoreRGB: 0x7CC8FF)
- /// Done. 3 s full-screen celebration on the box; done pills.
- static let mint = Color(korekoreRGB: 0x6EE7B7)
- /// Failed. Border and words on Ink, never a full red screen.
- static let peach = Color(korekoreRGB: 0xFF7A7A)
- /// Ready / idle and the ON DUTY label.
- static let lavender = Color(korekoreRGB: 0xB9A8FF)
- /// No link. The whole box screen goes gray; mist is for the big NO LINK word (4.1:1 on box-gray-ground, large text only).
- static let mist = Color(korekoreRGB: 0x8A85A3)
- /// Small text on the no-link screen (6.0:1 on box-gray-ground).
- static let mistText = Color(korekoreRGB: 0xA8A3BD)
- /// Done / watching / joined as TEXT on cream or paper (5.0:1 on cream; mint fails as text).
- static let doneText = Color(korekoreRGB: 0x177A56)
- /// Needs input / waiting as TEXT on cream or paper (5.3:1 on cream; sun fails as text).
- static let waitText = Color(korekoreRGB: 0x8A5F00)
- /// Working / busy as TEXT on cream or paper.
- static let workText = Color(korekoreRGB: 0x2E6FB0)
- /// The box's screen background in calm states.
- static let boxGround = Color(korekoreRGB: 0x1C1730)
- /// Task tickets and the side panel on the box; agent rows on Ink.
- static let boxCard = Color(korekoreRGB: 0x2A2345)
- /// Kore's screen-face; empty segments of meters on the box.
- static let boxFace = Color(korekoreRGB: 0x3B2F63)
- /// Primary text on the box. Also Kore's shell.
- static let boxText = Color(korekoreRGB: 0xFFF3DD)
- /// Project names, times, hints on the box.
- static let boxTextMuted = Color(korekoreRGB: 0xA9A2C2)
- /// Dimmed screen behind the box's MENU card.
- static let boxDim = Color(korekoreRGB: 0x0F0C1C)
- /// The box's ground while there is no link.
- static let boxGrayGround = Color(korekoreRGB: 0x2A2833)
- /// Kore's outline pixels only (sprite palette).
- static let koreOutline = Color(korekoreRGB: 0x2B2140)
- /// Cheeks on Kore and every Character.
- static let blush = Color(korekoreRGB: 0xFF9EB8)
- /// Kore's mouth pixels.
- static let koreMouth = Color(korekoreRGB: 0xFFB3C8)
- /// Claude Code tile.
- static let agentCc = Color(korekoreRGB: 0xF2A27E)
- /// Codex tile.
- static let agentCx = Color(korekoreRGB: 0x9DB4FF)
- /// GitHub Actions tile; General and Advanced nav tiles.
- static let agentCi = Color(korekoreRGB: 0xD9D3EA)
- /// OpenCode tile.
- static let agentOc = Color(korekoreRGB: 0x8FE3C4)
- /// GitHub Copilot CLI tile.
- static let agentGh = Color(korekoreRGB: 0xA8D8FF)
- /// Pi tile.
- static let agentPi = Color(korekoreRGB: 0xFFC59E)
- /// Your own events (POST /v1/events) tile; Conversation nav tile.
- static let agentApi = Color(korekoreRGB: 0xFFE58A)
- }
-
- /// The bundled fonts (Resources/fonts) by PostScript name.
- enum FontName {
- static let roundedRegular = "RoundedMplus1c-Regular"
- static let roundedBold = "RoundedMplus1c-Bold"
- static let roundedExtraBold = "RoundedMplus1c-ExtraBold"
- static let pixelRegular = "DotGothic16-Regular"
- }
-
- enum Typography {
- /// Rounded Mplus 1c 800. The wordmark, large lockups only.
- static let display = KorekoreTextStyle(font: "RoundedMplus1c-ExtraBold", size: 104, lineHeight: 93.6, letterSpacing: -3.12)
- /// Rounded Mplus 1c 800. Page title in the app's content area.
- static let title = KorekoreTextStyle(font: "RoundedMplus1c-ExtraBold", size: 28, lineHeight: 34, letterSpacing: 0)
- /// Rounded Mplus 1c 800. Onboarding headlines.
- static let hero = KorekoreTextStyle(font: "RoundedMplus1c-ExtraBold", size: 42, lineHeight: 47, letterSpacing: 0)
- /// Rounded Mplus 1c 800. Card titles, banner lines.
- static let heading = KorekoreTextStyle(font: "RoundedMplus1c-ExtraBold", size: 17, lineHeight: 22, letterSpacing: 0)
- /// Rounded Mplus 1c 800. Row names, nav items, character names.
- static let label = KorekoreTextStyle(font: "RoundedMplus1c-ExtraBold", size: 15, lineHeight: 20, letterSpacing: 0)
- /// Rounded Mplus 1c 800. Buttons and chips (12 px in dense rows).
- static let button = KorekoreTextStyle(font: "RoundedMplus1c-ExtraBold", size: 13, lineHeight: 18, letterSpacing: 0)
- /// Rounded Mplus 1c 400. Paragraphs, chat bubbles.
- static let body = KorekoreTextStyle(font: "RoundedMplus1c-Regular", size: 14, lineHeight: 21, letterSpacing: 0)
- /// Rounded Mplus 1c 400. Row details, captions.
- static let small = KorekoreTextStyle(font: "RoundedMplus1c-Regular", size: 12, lineHeight: 17, letterSpacing: 0)
- /// DotGothic16 400. The state word on the box (22-36 px on a takeover).
- static let boxState = KorekoreTextStyle(font: "DotGothic16-Regular", size: 24, lineHeight: 24, letterSpacing: 0)
- /// DotGothic16 400. Ticket titles on the box (9-12 px).
- static let boxText = KorekoreTextStyle(font: "DotGothic16-Regular", size: 11, lineHeight: 14, letterSpacing: 0)
- /// DotGothic16 400. Values, versions, ports in the app.
- static let pixelValue = KorekoreTextStyle(font: "DotGothic16-Regular", size: 13, lineHeight: 18, letterSpacing: 0)
- /// DotGothic16 400. State tags in the app: uppercase, state *-text colour, no background.
- static let pixelTag = KorekoreTextStyle(font: "DotGothic16-Regular", size: 12, lineHeight: 16, letterSpacing: 0)
- }
-
- enum Space {
- /// Inside pills, icon-to-text in pixel rows.
- static let s1: CGFloat = 4
- /// Gaps in button rows, between tickets on the box (px x 1.5 there).
- static let s2: CGFloat = 8
- /// Row padding, card grid gaps.
- static let s3: CGFloat = 12
- /// Card padding, content-area gaps.
- static let s4: CGFloat = 16
- /// Window inset, page padding.
- static let s5: CGFloat = 24
- /// Onboarding columns, board margins.
- static let s6: CGFloat = 40
- }
-
- enum Radius {
- /// Key chips (K0 K1 K2) and state pills on the box; pixel tags.
- static let pixel: CGFloat = 4
- /// Inner tiles: sprite stages, inputs, ticket rows on the box (8 px at 1x).
- static let sm: CGFloat = 10
- /// Cards and grouped lists.
- static let md: CGFloat = 16
- /// Windows and panels.
- static let lg: CGFloat = 20
- /// Hero panels (onboarding Kore stage).
- static let xl: CGFloat = 28
- /// Buttons, chips, toggles, tabs.
- static let pill: CGFloat = 999
- }
-
- enum Stroke {
- /// Pixel-tag outlines on the box and status squares.
- static let thin: CGFloat = 1.5
- /// Cards, buttons, toggles, inputs, chips.
- static let base: CGFloat = 2
- /// Windows, the selected character card (kore-pink), primary hero buttons.
- static let bold: CGFloat = 3
- }
-
- /// Solid ink drop shadows: how far down they fall.
- enum Sticker {
- /// Cards, list groups, the sidebar box mirror. Solid ink, no blur: the sticker look.
- static let sm: CGFloat = 4
- /// Hero panels, lockup cards, the menu bar panel.
- static let md: CGFloat = 6
- /// App windows.
- static let lg: CGFloat = 8
- }
-
- enum Pixel {
- /// Small bitmap tag (sidebar, title bar), solid, no grain.
- static let dotSm: CGFloat = 2
- /// Medium tag on Ink lockups.
- static let dotMd: CGFloat = 3
- /// Large tag: 3 px dot + 1 px grain gap, 1 px corner.
- static let dotLg: CGFloat = 4
- /// Kore and Characters in app cards: 1 sprite pixel = 4 px.
- static let spriteScaleApp: Int = 4
- /// Kore on the 320x240 box screen in takeovers (4 on duty).
- static let spriteScaleBox: Int = 5
- }
-}
-
-/// A type style: the font file, its size, line height and letter spacing, all in points.
-struct KorekoreTextStyle {
- let font: String
- let size: CGFloat
- let lineHeight: CGFloat
- let letterSpacing: CGFloat
-}
-
-extension Color {
- init(korekoreRGB rgb: UInt32) {
- self.init(.sRGB, red: Double(rgb >> 16 & 0xFF) / 255, green: Double(rgb >> 8 & 0xFF) / 255, blue: Double(rgb & 0xFF) / 255)
- }
-}
diff --git a/app/Sources/VibeBuddy/Korekore/KorekoreWindows.swift b/app/Sources/VibeBuddy/Korekore/KorekoreWindows.swift
new file mode 100644
index 00000000..c5b2d623
--- /dev/null
+++ b/app/Sources/VibeBuddy/Korekore/KorekoreWindows.swift
@@ -0,0 +1,91 @@
+import AppKit
+import Combine
+import KorekoreUI
+import SwiftUI
+import VibeBuddyCore
+
+/// The settings window with the AppModel behind it.
+struct KorekoreSettingsRoot: View {
+ @ObservedObject var model: AppModel
+ @State private var selection = KorekoreSection.character
+
+ var body: some View {
+ // Voice commands are experimental: no Conversation section while the daemon has them off.
+ let sections = KorekoreSection.allCases.filter { $0 != .conversation || model.residentEnabled }
+ KorekoreSettings(picture: BoxPicture.derive(status: model.status, daemonAlive: model.daemonAlive),
+ versions: KorekoreVersions(app: Resources.bundleVersion, firmware: model.status?.device.firmwareVersion),
+ sections: sections, selection: $selection) { _ in
+ KorekorePagePending()
+ }
+ }
+}
+
+/// The menu bar item and the panel it opens (canvas: "Mac · Menu bar"); the same `AppModel` feeds both.
+@MainActor
+final class KorekoreStatusItem {
+ private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ private let model: AppModel
+ private let actions: KorekoreMenuActions
+ private var panel: NSPanel?
+ private var outsideClicks: Any?
+ private var subscriptions: Set = []
+
+ init(model: AppModel, actions: KorekoreMenuActions) {
+ self.model = model
+ self.actions = actions
+ statusItem.button?.target = self
+ statusItem.button?.action = #selector(toggle)
+ model.$status.combineLatest(model.$daemonAlive).receive(on: RunLoop.main).sink { [weak self] status, alive in
+ self?.statusItem.button?.image = KorekoreMenuIcon.image(for: BoxPicture.derive(status: status, daemonAlive: alive))
+ }.store(in: &subscriptions)
+ }
+
+ @objc private func toggle() {
+ if panel != nil { close() } else { open() }
+ }
+
+ private func open() {
+ guard let button = statusItem.button, let buttonWindow = button.window else { return }
+ let close = { [weak self] (action: @escaping () -> Void) in { self?.close(); action() } }
+ let actions = KorekoreMenuActions(settings: close(actions.settings), checkForUpdates: close(actions.checkForUpdates),
+ reportProblem: close(actions.reportProblem), quit: actions.quit,
+ restartDaemon: close(actions.restartDaemon))
+ let hosting = NSHostingView(rootView: KorekorePanelRoot(model: model, actions: actions))
+ let size = hosting.fittingSize
+ let anchor = buttonWindow.convertToScreen(button.convert(button.bounds, to: nil))
+ let screen = buttonWindow.screen?.visibleFrame ?? .zero
+ // Right edge under the item, kept on screen, 6 pt below the menu bar.
+ let x = min(anchor.maxX - size.width + 24, screen.maxX - size.width - 8)
+ let panel = NSPanel(contentRect: NSRect(x: x, y: anchor.minY - 6 - size.height, width: size.width, height: size.height),
+ styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
+ panel.isOpaque = false
+ panel.backgroundColor = .clear
+ panel.hasShadow = false
+ panel.level = .popUpMenu
+ panel.contentView = hosting
+ panel.orderFrontRegardless()
+ self.panel = panel
+ statusItem.button?.highlight(true)
+ // Like a menu: a click anywhere else closes it.
+ outsideClicks = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] _ in
+ MainActor.assumeIsolated { self?.close() }
+ }
+ }
+
+ private func close() {
+ panel?.orderOut(nil)
+ panel = nil
+ statusItem.button?.highlight(false)
+ if let outsideClicks { NSEvent.removeMonitor(outsideClicks) }
+ outsideClicks = nil
+ }
+}
+
+private struct KorekorePanelRoot: View {
+ @ObservedObject var model: AppModel
+ let actions: KorekoreMenuActions
+
+ var body: some View {
+ KorekoreMenuPanel(picture: BoxPicture.derive(status: model.status, daemonAlive: model.daemonAlive), actions: actions)
+ }
+}
diff --git a/app/Sources/VibeBuddyCore/Korekore/BoxPicture.swift b/app/Sources/VibeBuddyCore/Korekore/BoxPicture.swift
new file mode 100644
index 00000000..f005337d
--- /dev/null
+++ b/app/Sources/VibeBuddyCore/Korekore/BoxPicture.swift
@@ -0,0 +1,90 @@
+import Foundation
+
+/// What the korekore UI shows of the box (ADR-0021): the mini box in the sidebar, the menu bar panel and its icon.
+/// It is drawn from the daemon's status, not from a screen capture, so it costs nothing over the link.
+/// desktop/src/korekore/picture.rs derives the same for Linux.
+public struct BoxPicture: Equatable {
+ public enum Mood: Equatable, Sendable {
+ case ready, working, input, pomodoro, leisure, noLink
+ }
+
+ public enum Link: String, Equatable, Sendable {
+ case usb = "USB", lan = "LAN", relay = "RELAY"
+ }
+
+ /// A task card as the box shows it: the agent's tile and the title without its prefix.
+ public struct Ticket: Equatable, Sendable {
+ public var icon: String
+ public var title: String
+ public var needsInput: Bool
+ }
+
+ public var mood: Mood
+ public var daemonAlive: Bool
+ /// How the box is linked; nil without a link.
+ public var link: Link?
+ /// `duty`, `pomodoro` or `leisure`, as the box last said.
+ public var mode: String?
+ /// The newest three, as the box's first page shows them.
+ public var tickets: [Ticket]
+ /// Every Activity the box is watching.
+ public var activityCount: Int
+ public var today: TodaySummary
+ public var firmwareVersion: String?
+
+ /// The box can show this many cards on a page.
+ public static let ticketsShown = 3
+
+ public static func derive(status: Status?, daemonAlive: Bool) -> BoxPicture {
+ let activities = status?.activities ?? []
+ let device = status?.device
+ let linked = daemonAlive && device?.connected == true
+ let link: Link? = !linked ? nil : device?.relay == true ? .relay : device?.network == true ? .lan : .usb
+ let mood: Mood
+ if !linked {
+ mood = .noLink
+ } else if device?.mode == "pomodoro" {
+ mood = .pomodoro
+ } else if device?.mode == "leisure" {
+ mood = .leisure
+ } else if activities.contains(where: { $0.state == "needs_input" }) {
+ mood = .input
+ } else if !activities.isEmpty {
+ mood = .working
+ } else {
+ mood = .ready
+ }
+ let tickets = activities.prefix(ticketsShown).map {
+ Ticket(icon: icon(forAgent: $0.agent), title: cardTitle($0.title), needsInput: $0.state == "needs_input")
+ }
+ return BoxPicture(mood: mood, daemonAlive: daemonAlive, link: link, mode: device?.mode, tickets: Array(tickets),
+ activityCount: activities.count, today: status?.today ?? TodaySummary(done: 0, asks: 0, busySeconds: 0),
+ firmwareVersion: device?.firmwareVersion)
+ }
+
+ /// The agent's tile in docs/design/korekore/pixel/icons.json; anything else is the user's own events.
+ public static func icon(forAgent agent: String) -> String {
+ switch agent {
+ case "claude-code": "cc"
+ case "codex": "cx"
+ case "github-actions": "ci"
+ case "opencode": "oc"
+ case "copilot": "gh"
+ case "pi": "pi"
+ default: "api"
+ }
+ }
+
+ /// The title without the prefix the daemon adds to tell agents apart (`CC:`, `CX:`…): the tile says that now.
+ public static func cardTitle(_ title: String) -> String {
+ guard let colon = title.firstIndex(of: ":"), title.distance(from: title.startIndex, to: colon) <= 3,
+ title[.. String {
+ let minutes = seconds / 60
+ return minutes < 60 ? "\(minutes)m" : "\(minutes / 60)h\(String(format: "%02d", minutes % 60))m"
+ }
+}
diff --git a/app/Sources/VibeBuddyCore/Korekore/PixelSprite.swift b/app/Sources/VibeBuddyCore/Korekore/PixelSprite.swift
index ff208390..84de62ec 100644
--- a/app/Sources/VibeBuddyCore/Korekore/PixelSprite.swift
+++ b/app/Sources/VibeBuddyCore/Korekore/PixelSprite.swift
@@ -34,6 +34,21 @@ public struct PixelSprite: Sendable {
}
return bytes
}
+
+ /// The sprite in gray, as CSS `grayscale(1) brightness(b)` draws it: each colour's luminance (Rec. 709
+ /// weights), scaled by `brightness`. The canvas grays Kore this way when the box has no link.
+ public func grayed(brightness: Double) -> PixelSprite {
+ // Typed step by step: as one expression it is too slow for the type checker on CI's Xcode.
+ let palette = palette.map { (rgb: UInt32) -> UInt32 in
+ let r: Double = Double((rgb >> 16) & 0xFF)
+ let g: Double = Double((rgb >> 8) & 0xFF)
+ let b: Double = Double(rgb & 0xFF)
+ let luminance: Double = 0.2126 * r + 0.7152 * g + 0.0722 * b
+ let level: UInt32 = UInt32(Swift.min(255.0, Swift.max(0.0, (luminance * brightness).rounded())))
+ return (level << 16) | (level << 8) | level
+ }
+ return PixelSprite(width: width, height: height, palette: palette, pixels: pixels)
+ }
}
/// Which settings window, onboarding and menu bar the app shows (ADR-0021). The korekore UI opens only with
diff --git a/app/Sources/VibeBuddyCore/Status.swift b/app/Sources/VibeBuddyCore/Status.swift
index dfb32a18..4c893a8d 100644
--- a/app/Sources/VibeBuddyCore/Status.swift
+++ b/app/Sources/VibeBuddyCore/Status.swift
@@ -10,6 +10,9 @@ public struct Status: Codable, Equatable {
public var config: DaemonConfig
/// What the update manifest offers (ADR-0010); absent from older daemons.
public var updates: UpdateStatus?
+ /// Every Activity, newest first, as the box's task cards show them; absent from older daemons.
+ public var activities: [ActivitySummary]?
+
/// The person's VibeBuddy account (ADR-0018); absent from older daemons, which can't sign in.
public var account: AccountStatus?
/// Which of the user's own keys for spoken replies are set (ADR-0020); absent from older daemons.
@@ -18,9 +21,9 @@ public struct Status: Codable, Equatable {
/// and from older daemons.
public var making: MakingStatus?
- enum CodingKeys: String, CodingKey { case daemon, device, today, hooks, operation, config, updates, account, voiceKeys = "voice_keys", making }
+ enum CodingKeys: String, CodingKey { case daemon, device, today, hooks, operation, config, updates, account, voiceKeys = "voice_keys", making, activities }
- public init(daemon: DaemonInfo, device: DeviceState, today: TodaySummary, hooks: HooksSeen, operation: DeviceOperation?, config: DaemonConfig, updates: UpdateStatus? = nil, account: AccountStatus? = nil, voiceKeys: VoiceKeysStatus? = nil, making: MakingStatus? = nil) {
+ public init(daemon: DaemonInfo, device: DeviceState, today: TodaySummary, hooks: HooksSeen, operation: DeviceOperation?, config: DaemonConfig, updates: UpdateStatus? = nil, account: AccountStatus? = nil, voiceKeys: VoiceKeysStatus? = nil, making: MakingStatus? = nil, activities: [ActivitySummary]? = nil) {
self.daemon = daemon
self.device = device
self.today = today
@@ -28,12 +31,31 @@ public struct Status: Codable, Equatable {
self.operation = operation
self.config = config
self.updates = updates
+ self.activities = activities
self.account = account
self.voiceKeys = voiceKeys
self.making = making
}
}
+/// One Activity as the daemon summarises it (daemon/src/activity.rs `ActivitySummary`).
+public struct ActivitySummary: Codable, Equatable, Sendable {
+ /// `codex`, `claude-code`, `github-actions`, or the agent a CLI hook names (`opencode`, `copilot`, `pi`).
+ public var agent: String
+ /// The task card's title, with the prefix that tells agents apart (`CC:`, `CX:`…).
+ public var title: String
+ public var project: String?
+ /// `working` or `needs_input`.
+ public var state: String
+
+ public init(agent: String, title: String, project: String? = nil, state: String) {
+ self.agent = agent
+ self.title = title
+ self.project = project
+ self.state = state
+ }
+}
+
/// The daemon says only whether each key is set, and whether its provider refused it; a key never comes back.
public struct VoiceKeysStatus: Codable, Equatable {
public struct Key: Codable, Equatable {
diff --git a/daemon/src/main.rs b/daemon/src/main.rs
index b9fc84f6..732bad9c 100644
--- a/daemon/src/main.rs
+++ b/daemon/src/main.rs
@@ -177,6 +177,10 @@ impl AppState {
let mut device = self.device.lock().await.clone();
device.relay_refused = self.relay.as_ref().and_then(relay::RelayConfig::refused);
let updates = self.updates.lock().await.status(&device, self.update_checks_enabled().await);
+ let (today, activities) = {
+ let tracker = self.activities.lock().await;
+ (tracker.today(), tracker.summaries())
+ };
Status {
daemon: DaemonInfo {
build: build_identity(self.app_version.as_deref()),
@@ -184,7 +188,8 @@ impl AppState {
app_version: self.app_version.clone(),
},
device,
- today: self.activities.lock().await.today(),
+ today,
+ activities,
hooks: self.hooks_seen.lock().await.clone(),
operation: self.operation.lock().await.clone(),
config: self.config.lock().await.clone(),
@@ -1477,6 +1482,8 @@ async fn sweep_expired_activities(state: AppState) {
/// Shared path for background tasks sending events. A full queue or encoding failure is only logged and doesn't affect the next round.
fn send_event(state: &AppState, event: Event) {
+ // What the box shows changed, so the apps' picture of it (the Activities in the status) did too.
+ state.notify_status();
match event.to_ndjson() {
Ok(frame) => {
if let Err(error) = state.transport.send(frame) {
@@ -2172,6 +2179,7 @@ mod tests {
.unwrap();
let _ = post_codex_hook(State(state.clone()), Json(hook)).await;
assert_eq!(state.activities.lock().await.summaries().len(), 1);
+ assert_eq!(state.snapshot().await.activities.len(), 1, "the status carries the Activities");
let _ = std::fs::remove_dir_all(dir);
}
diff --git a/daemon/src/status.rs b/daemon/src/status.rs
index a12604b6..5e0b0998 100644
--- a/daemon/src/status.rs
+++ b/daemon/src/status.rs
@@ -345,6 +345,8 @@ pub struct Status {
pub daemon: DaemonInfo,
pub device: DeviceState,
pub today: TodaySummary,
+ /// Every Activity, newest first: what the box's task cards show, for the apps' picture of the box (ADR-0021).
+ pub activities: Vec,
pub hooks: HooksSeen,
pub operation: Option,
pub config: Config,
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index fd742505..b6b9acf2 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -28,3 +28,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
ksni = "0.3"
# The file chooser goes through the desktop portal, as other apps' do.
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] }
+
+[dev-dependencies]
+# Draws the korekore views headless into PNGs, to set beside the design canvas (src/korekore/preview.rs).
+iced_test = "0.14"
diff --git a/desktop/src/korekore/mod.rs b/desktop/src/korekore/mod.rs
index d4d963f1..d9c90dbe 100644
--- a/desktop/src/korekore/mod.rs
+++ b/desktop/src/korekore/mod.rs
@@ -5,12 +5,18 @@
// The views that use all this come in the next steps.
#![allow(dead_code)]
+pub mod picture;
+#[cfg(test)]
+mod snapshots;
pub mod sprites;
pub mod theme;
+pub mod view;
use iced::Task;
use iced::widget::image;
+use crate::i18n::tr;
+
/// Whether this launch asked for the korekore UI. Read once at launch.
pub fn requested() -> bool {
std::env::var("VIBEBUDDY_UI").is_ok_and(|ui| ui == "korekore")
@@ -41,21 +47,22 @@ impl Sprite {
/// RGBA bytes at `scale` whole times the size, nearest-neighbour. `tint` (0xRRGGBB) replaces every colour,
/// for the one-colour tag.
pub fn rgba(&self, scale: u32, tint: Option) -> Vec {
- let (width, height) = (self.width * scale, self.height * scale);
- let mut bytes = Vec::with_capacity((width * height * 4) as usize);
- for y in 0..height {
- for x in 0..width {
- let index = self.pixels[((y / scale) * self.width + x / scale) as usize];
- bytes.extend_from_slice(&match index.checked_sub(1) {
- None => [0; 4],
- Some(n) => {
- let rgb = tint.unwrap_or(self.palette[usize::from(n)]);
- [(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, 0xFF]
- }
- });
- }
- }
- bytes
+ rgba(self.width, self.height, self.palette, self.pixels, scale, tint)
+ }
+
+ /// The sprite in gray, as CSS `grayscale(1) brightness(b)` draws it: each colour's luminance (Rec. 709
+ /// weights), scaled by `brightness`. The canvas grays Kore this way when the box has no link.
+ pub fn grayed(&self, brightness: f32) -> GraySprite {
+ let palette = self
+ .palette
+ .iter()
+ .map(|&rgb| {
+ let (r, g, b) = ((rgb >> 16 & 0xFF) as f32, (rgb >> 8 & 0xFF) as f32, (rgb & 0xFF) as f32);
+ let level = ((0.2126 * r + 0.7152 * g + 0.0722 * b) * brightness).round().clamp(0.0, 255.0) as u32;
+ level << 16 | level << 8 | level
+ })
+ .collect();
+ GraySprite { width: self.width, height: self.height, palette, pixels: self.pixels }
}
/// An image of the sprite at `scale` whole times its size; show it at that size, so nothing resamples it.
@@ -64,6 +71,128 @@ impl Sprite {
}
}
+/// A sprite with its palette grayed; drawn like any other.
+pub struct GraySprite {
+ width: u32,
+ height: u32,
+ palette: Vec,
+ pixels: &'static [u8],
+}
+
+impl GraySprite {
+ pub fn rgba(&self, scale: u32) -> Vec {
+ rgba(self.width, self.height, &self.palette, self.pixels, scale, None)
+ }
+
+ pub fn handle(&self, scale: u32, tint: Option) -> image::Handle {
+ let bytes = rgba(self.width, self.height, &self.palette, self.pixels, scale, tint);
+ image::Handle::from_rgba(self.width * scale, self.height * scale, bytes)
+ }
+}
+
+fn rgba(width: u32, height: u32, palette: &[u32], pixels: &[u8], scale: u32, tint: Option) -> Vec {
+ let (out_width, out_height) = (width * scale, height * scale);
+ let mut bytes = Vec::with_capacity((out_width * out_height * 4) as usize);
+ for y in 0..out_height {
+ for x in 0..out_width {
+ let index = pixels[((y / scale) * width + x / scale) as usize];
+ bytes.extend_from_slice(&match index.checked_sub(1) {
+ None => [0; 4],
+ Some(n) => {
+ let rgb = tint.unwrap_or(palette[usize::from(n)]);
+ [(rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, 0xFF]
+ }
+ });
+ }
+ }
+ bytes
+}
+
+/// The tray's Kore (canvas: "Mac · Menu bar", bottom row): in colour online, with a `sun` dot beside it when an
+/// agent needs you, gray and asleep without a link. ARGB32 rows, as StatusNotifierItem pixmaps are.
+pub fn tray_icon(picture: &picture::BoxPicture, scale: u32) -> (u32, u32, Vec) {
+ // The dot is the design's 7 px circle with its 1.5 px ink edge, at whole pixels.
+ const DOT: [&str; 7] = ["..iii..", ".isssi.", "isssssi", "isssssi", "isssssi", ".isssi.", "..iii.."];
+ let needs_you = picture.needs_you();
+ let kore = if needs_you { &sprites::KORE_INPUT } else { &sprites::KORE_NORMAL };
+ let rgba = if picture.mood == picture::Mood::NoLink {
+ sprites::KORE_SLEEP.grayed(0.75).rgba(scale)
+ } else {
+ kore.rgba(scale, None)
+ };
+ let kore_width = kore.width * scale;
+ let width = if needs_you { kore_width + (4 + 7) * scale } else { kore_width };
+ let height = kore.height * scale;
+ let mut out = vec![0u8; (width * height * 4) as usize];
+ for y in 0..height {
+ for x in 0..width {
+ let pixel = if x < kore_width {
+ let at = ((y * kore_width + x) * 4) as usize;
+ [rgba[at + 3], rgba[at], rgba[at + 1], rgba[at + 2]]
+ } else {
+ let (dx, dy) = ((x - kore_width) / scale, y / scale);
+ let row = dy.checked_sub((kore.height - 7) / 2).and_then(|row| DOT.get(row as usize));
+ match row.and_then(|row| dx.checked_sub(4).and_then(|col| row.as_bytes().get(col as usize))) {
+ Some(b'i') => [0xFF, 0x1C, 0x17, 0x30],
+ Some(b's') => [0xFF, 0xFF, 0xD2, 0x3F],
+ _ => [0; 4],
+ }
+ };
+ out[((y * width + x) * 4) as usize..][..4].copy_from_slice(&pixel);
+ }
+ }
+ (width, height, out)
+}
+
+/// The settings window's sections, in the sidebar's order (docs/design/korekore/README.md).
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Section {
+ Character,
+ Agents,
+ Conversation,
+ Device,
+ General,
+ Advanced,
+}
+
+impl Section {
+ pub const ALL: [Section; 6] =
+ [Section::Character, Section::Agents, Section::Conversation, Section::Device, Section::General, Section::Advanced];
+
+ pub fn title(self) -> String {
+ match self {
+ Section::Character => tr("Character", &[]),
+ Section::Agents => tr("Agents", &[]),
+ Section::Conversation => tr("Conversation", &[]),
+ Section::Device => tr("Device", &[]),
+ Section::General => tr("General", &[]),
+ Section::Advanced => tr("Advanced", &[]),
+ }
+ }
+
+ pub fn subtitle(self) -> String {
+ match self {
+ Section::Character => tr("Who lives in the box", &[]),
+ Section::Agents => tr("Who the box watches", &[]),
+ Section::Conversation => tr("Hold K0 on the box and ask", &[]),
+ Section::Device => tr("The box, its firmware and its network", &[]),
+ Section::General => tr("How korekore behaves on this computer", &[]),
+ Section::Advanced => tr("For when something's off", &[]),
+ }
+ }
+
+ pub fn tile(self) -> &'static str {
+ match self {
+ Section::Character => "icon-nav-char",
+ Section::Agents => "icon-nav-agents",
+ Section::Conversation => "icon-nav-chat",
+ Section::Device => "icon-nav-box",
+ Section::General => "icon-nav-gen",
+ Section::Advanced => "icon-nav-adv",
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -97,6 +226,21 @@ mod tests {
}
}
+ #[test]
+ fn the_tray_icon_adds_a_sun_dot_only_when_an_agent_needs_you() {
+ use crate::status::{Activity, Status};
+ let mut status = Status::default();
+ status.device.connected = true;
+ let (width, height, pixels) = tray_icon(&picture::BoxPicture::derive(Some(&status)), 2);
+ assert_eq!((width, height, pixels.len()), (40, 36, 40 * 36 * 4));
+ status.activities = vec![Activity { agent: "codex".into(), title: "CX:Ask".into(), state: "needs_input".into(), ..Activity::default() }];
+ let (width, _, pixels) = tray_icon(&picture::BoxPicture::derive(Some(&status)), 2);
+ assert_eq!(width, 62);
+ // The dot's centre, in ARGB: sun.
+ let at = ((17 * 62 + 40 + 2 * 7) * 4) as usize;
+ assert_eq!(&pixels[at..at + 4], [0xFF, 0xFF, 0xD2, 0x3F]);
+ }
+
#[test]
fn the_theme_matches_the_tokens() {
assert_eq!(theme::INK, iced::Color::from_rgb8(0x1C, 0x17, 0x30));
diff --git a/desktop/src/korekore/picture.rs b/desktop/src/korekore/picture.rs
new file mode 100644
index 00000000..f4f04ff0
--- /dev/null
+++ b/desktop/src/korekore/picture.rs
@@ -0,0 +1,193 @@
+//! What the korekore UI shows of the box (ADR-0021): the mini box in the sidebar and the tray's Kore. It is drawn
+//! from the daemon's status, not from a screen capture, so it costs nothing over the link. The Mac derives the same
+//! in `BoxPicture.swift`.
+
+use crate::status::{Status, Today};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Mood {
+ Ready,
+ Working,
+ Input,
+ Pomodoro,
+ Leisure,
+ NoLink,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Link {
+ Usb,
+ Lan,
+ Relay,
+}
+
+impl Link {
+ pub fn label(self) -> &'static str {
+ match self {
+ Link::Usb => "USB",
+ Link::Lan => "LAN",
+ Link::Relay => "RELAY",
+ }
+ }
+}
+
+/// A task card as the box shows it: the agent's tile and the title without its prefix.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Ticket {
+ pub icon: &'static str,
+ pub title: String,
+ pub needs_input: bool,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct BoxPicture {
+ pub mood: Mood,
+ pub daemon_alive: bool,
+ /// How the box is linked; None without a link.
+ pub link: Option ,
+ /// The newest three, as the box's first page shows them.
+ pub tickets: Vec,
+ pub today: Today,
+ pub firmware_version: Option,
+}
+
+/// The box can show this many cards on a page.
+const TICKETS_SHOWN: usize = 3;
+
+impl BoxPicture {
+ /// `None` means the daemon isn't answering.
+ pub fn derive(status: Option<&Status>) -> Self {
+ let activities = status.map(|status| status.activities.as_slice()).unwrap_or_default();
+ let device = status.map(|status| &status.device);
+ let link = device.filter(|device| device.connected).map(|device| {
+ if device.relay {
+ Link::Relay
+ } else if device.network {
+ Link::Lan
+ } else {
+ Link::Usb
+ }
+ });
+ let mood = match (link, device.and_then(|device| device.mode.as_deref())) {
+ (None, _) => Mood::NoLink,
+ (_, Some("pomodoro")) => Mood::Pomodoro,
+ (_, Some("leisure")) => Mood::Leisure,
+ _ if activities.iter().any(|activity| activity.state == "needs_input") => Mood::Input,
+ _ if !activities.is_empty() => Mood::Working,
+ _ => Mood::Ready,
+ };
+ let tickets = activities
+ .iter()
+ .take(TICKETS_SHOWN)
+ .map(|activity| Ticket {
+ icon: icon_for_agent(&activity.agent),
+ title: card_title(&activity.title).to_owned(),
+ needs_input: activity.state == "needs_input",
+ })
+ .collect();
+ Self {
+ mood,
+ daemon_alive: status.is_some(),
+ link,
+ tickets,
+ today: status.map(|status| status.today.clone()).unwrap_or_default(),
+ firmware_version: device.and_then(|device| device.firmware_version.clone()),
+ }
+ }
+
+ pub fn needs_you(&self) -> bool {
+ self.tickets.iter().any(|ticket| ticket.needs_input)
+ }
+}
+
+/// The agent's tile in docs/design/korekore/pixel/icons.json; anything else is the user's own events.
+pub fn icon_for_agent(agent: &str) -> &'static str {
+ match agent {
+ "claude-code" => "cc",
+ "codex" => "cx",
+ "github-actions" => "ci",
+ "opencode" => "oc",
+ "copilot" => "gh",
+ "pi" => "pi",
+ _ => "api",
+ }
+}
+
+/// The title without the prefix the daemon adds to tell agents apart (`CC:`, `CX:`…): the tile says that now.
+pub fn card_title(title: &str) -> &str {
+ match title.split_once(':') {
+ Some((prefix, rest))
+ if prefix.chars().count() <= 3 && prefix.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) =>
+ {
+ rest.trim_start()
+ }
+ _ => title,
+ }
+}
+
+/// Busy time as the stat chips show it: `35m`, `4h12m`.
+pub fn busy(seconds: u64) -> String {
+ let minutes = seconds / 60;
+ if minutes < 60 { format!("{minutes}m") } else { format!("{}h{:02}m", minutes / 60, minutes % 60) }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::status::Activity;
+
+ fn status(connected: bool, mode: &str, activities: &[(&str, &str, &str)]) -> Status {
+ let mut status = Status::default();
+ status.device.connected = connected;
+ status.device.mode = Some(mode.to_owned());
+ status.activities = activities
+ .iter()
+ .map(|(agent, title, state)| Activity {
+ agent: (*agent).to_owned(),
+ title: (*title).to_owned(),
+ state: (*state).to_owned(),
+ ..Activity::default()
+ })
+ .collect();
+ status
+ }
+
+ #[test]
+ fn the_mood_follows_the_link_the_mode_and_the_cards() {
+ assert_eq!(BoxPicture::derive(None).mood, Mood::NoLink);
+ assert_eq!(BoxPicture::derive(Some(&status(false, "duty", &[]))).mood, Mood::NoLink);
+ assert_eq!(BoxPicture::derive(Some(&status(true, "duty", &[]))).mood, Mood::Ready);
+ let working = [("codex", "CX:Fix", "working")];
+ assert_eq!(BoxPicture::derive(Some(&status(true, "duty", &working))).mood, Mood::Working);
+ let asking = [("codex", "CX:Fix", "working"), ("claude-code", "CC:Ask", "needs_input")];
+ assert_eq!(BoxPicture::derive(Some(&status(true, "duty", &asking))).mood, Mood::Input);
+ assert_eq!(BoxPicture::derive(Some(&status(true, "pomodoro", &asking))).mood, Mood::Pomodoro);
+ }
+
+ #[test]
+ fn tickets_are_the_newest_three_with_tiles_and_no_prefix() {
+ let four = [
+ ("claude-code", "CC:Add Chinese to the box", "working"),
+ ("codex", "CX:Review changes", "needs_input"),
+ ("github-actions", "CI:App tests", "working"),
+ ("pi", "PI:Older", "working"),
+ ];
+ let picture = BoxPicture::derive(Some(&status(true, "duty", &four)));
+ let tickets: Vec<(&str, &str, bool)> =
+ picture.tickets.iter().map(|ticket| (ticket.icon, ticket.title.as_str(), ticket.needs_input)).collect();
+ assert_eq!(
+ tickets,
+ [("cc", "Add Chinese to the box", false), ("cx", "Review changes", true), ("ci", "App tests", false)]
+ );
+ assert!(picture.needs_you());
+ }
+
+ #[test]
+ fn only_a_short_capital_prefix_is_dropped() {
+ assert_eq!(card_title("CX:Review"), "Review");
+ assert_eq!(card_title("Fix: the build"), "Fix: the build");
+ assert_eq!(card_title("No prefix"), "No prefix");
+ assert_eq!(busy(35 * 60), "35m");
+ assert_eq!(busy(4 * 3600 + 12 * 60), "4h12m");
+ }
+}
diff --git a/desktop/src/korekore/snapshots.rs b/desktop/src/korekore/snapshots.rs
new file mode 100644
index 00000000..4ba2affd
--- /dev/null
+++ b/desktop/src/korekore/snapshots.rs
@@ -0,0 +1,60 @@
+//! Draws the korekore views headless, with the canvas's sample data, into PNGs to set beside the design's boards:
+//! `cargo test -p vibebuddy-desktop korekore::snapshots -- --ignored`, then look in target/korekore-preview.
+
+use iced::{Element, Settings, Theme};
+use iced_test::simulator::Simulator;
+
+use super::picture::BoxPicture;
+use super::{FONTS, Section, view};
+use crate::status::{Activity, Status};
+
+fn picture(connected: bool, activities: &[(&str, &str, &str)]) -> BoxPicture {
+ let mut status = Status::default();
+ status.device.connected = connected;
+ status.device.mode = Some("duty".to_owned());
+ status.device.firmware_version = Some("0.5.0".to_owned());
+ status.today.done = 7;
+ status.today.asks = 3;
+ status.today.busy_seconds = 15_120;
+ status.activities = activities
+ .iter()
+ .map(|(agent, title, state)| Activity {
+ agent: (*agent).to_owned(),
+ title: (*title).to_owned(),
+ state: (*state).to_owned(),
+ ..Activity::default()
+ })
+ .collect();
+ BoxPicture::derive(Some(&status))
+}
+
+fn save(name: &str, size: (f32, f32), element: Element<'_, view::Pick>) {
+ let settings = Settings { fonts: FONTS.iter().map(|font| (*font).into()).collect(), ..Settings::default() };
+ let mut simulator = Simulator::with_size(settings, size, element);
+ let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../target/korekore-preview").join(format!("{name}.png"));
+ // The simulator only writes a file that isn't there yet.
+ let written = path.with_file_name(format!("{name}-tiny-skia.png"));
+ let _ = std::fs::remove_file(&written);
+ simulator.snapshot(&Theme::Light).unwrap().matches_image(&path).unwrap();
+ println!("wrote {}", written.display());
+}
+
+#[test]
+#[ignore = "writes PNGs for review; run with --ignored"]
+fn draw_the_korekore_views() {
+ let working = picture(
+ true,
+ &[
+ ("claude-code", "CC:Add Chinese to the box", "working"),
+ ("codex", "CX:Fix voice reply", "working"),
+ ("github-actions", "CI:App tests", "working"),
+ ],
+ );
+ let asking = picture(true, &[("codex", "CX:Review changes", "needs_input"), ("claude-code", "CC:Add Chinese to the box", "working")]);
+ // The window's inside, as on the canvas (960 × 772 less its 3 px outline).
+ save("window-character", (954.0, 766.0), view::settings(&working, &Section::ALL, Section::Character, "0.5.0", view::pending()));
+ save("window-device-asking", (954.0, 766.0), view::settings(&asking, &Section::ALL, Section::Device, "0.5.0", view::pending()));
+ for (name, sample) in [("working", &working), ("asking", &asking), ("quiet", &picture(true, &[])), ("no-link", &picture(false, &[]))] {
+ save(&format!("minibox-{name}"), (214.0, 166.0), view::mini_box(sample));
+ }
+}
diff --git a/desktop/src/korekore/view.rs b/desktop/src/korekore/view.rs
new file mode 100644
index 00000000..42622b8c
--- /dev/null
+++ b/desktop/src/korekore/view.rs
@@ -0,0 +1,285 @@
+//! The korekore settings window (canvas: "Mac · Character (sidebar)" and its siblings): the sidebar with the mini
+//! box, and the section's page beside it. The same layout as the Mac's `KorekoreUI`, in iced. Omarchy draws the
+//! window's frame here, so the view is the inside of the design's window: no outline or shadow of its own.
+
+use iced::widget::{Space, button, column, container, image, row, text};
+use iced::{Alignment, Background, Border, Color, Element, Font, Length, Padding, Shadow, Vector};
+
+use super::picture::{BoxPicture, Mood};
+use super::sprites;
+use super::theme::{self, typography};
+use super::{Section, Sprite};
+use crate::i18n::tr;
+
+/// What the view sends back: the user picked a section.
+#[derive(Clone, Copy, Debug)]
+pub struct Pick(pub Section);
+
+const ROUNDED: &str = "Rounded Mplus 1c";
+const PIXEL: Font = typography::BOX_TEXT.font;
+
+fn rounded(weight: iced::font::Weight) -> Font {
+ Font { family: iced::font::Family::Name(ROUNDED), weight, ..Font::DEFAULT }
+}
+
+fn sprite(sprite: &Sprite, scale: u32, gray: bool) -> image::Image {
+ sized(sprite, scale, gray, scale as f32)
+}
+
+/// A sprite drawn at `scale` whole times and shown at `shown` points per pixel, never resampled smoothly.
+fn sized(sprite: &Sprite, scale: u32, gray: bool, shown: f32) -> image::Image {
+ let handle = if gray { sprite.grayed(0.75).handle(scale, None) } else { sprite.handle(scale, None) };
+ image(handle)
+ .width(Length::Fixed(sprite.width as f32 * shown))
+ .height(Length::Fixed(sprite.height as f32 * shown))
+ .filter_method(image::FilterMethod::Nearest)
+}
+
+fn sticker(fill: Color, radius: f32, stroke: f32, shadow: f32) -> container::Style {
+ container::Style {
+ background: Some(Background::Color(fill)),
+ border: Border { color: theme::INK, width: stroke, radius: radius.into() },
+ shadow: Shadow { color: theme::INK, offset: Vector::new(0.0, shadow), blur_radius: 0.0 },
+ ..container::Style::default()
+ }
+}
+
+fn mood_word(mood: Mood) -> &'static str {
+ match mood {
+ Mood::Ready => "READY",
+ Mood::Working => "WORKING",
+ Mood::Input => "ASK",
+ Mood::Pomodoro => "POMODORO",
+ Mood::Leisure => "LEISURE",
+ Mood::NoLink => "NO LINK",
+ }
+}
+
+fn mood_color(mood: Mood) -> Color {
+ match mood {
+ Mood::Ready | Mood::Leisure => theme::LAVENDER,
+ Mood::Working => theme::SKY,
+ Mood::Input => theme::SUN,
+ Mood::Pomodoro => theme::KORE_PINK,
+ Mood::NoLink => theme::MIST,
+ }
+}
+
+fn kore(mood: Mood) -> &'static Sprite {
+ match mood {
+ Mood::Ready | Mood::Pomodoro | Mood::Leisure => &sprites::KORE_NORMAL,
+ Mood::Working => &sprites::KORE_WORKING,
+ Mood::Input => &sprites::KORE_INPUT,
+ Mood::NoLink => &sprites::KORE_SLEEP,
+ }
+}
+
+fn tile(name: &str) -> &'static Sprite {
+ sprites::ALL.iter().find(|(key, _)| *key == name).map(|(_, sprite)| *sprite).unwrap_or(&sprites::ICON_API)
+}
+
+/// The box's screen redrawn (canvas: "App sidebar component"): laid out at the box's 320 × 240 and shown at 0.65,
+/// 208 × 156, as the design does, inside a 3 px outline.
+pub fn mini_box<'a, M: 'a>(picture: &BoxPicture) -> Element<'a, M> {
+ const S: f32 = 0.65;
+ // iced clips square, so the screen's own corners are rounded to sit inside the outline.
+ const INNER: f32 = theme::RADIUS_MD - theme::STROKE_BOLD;
+ let screen: Element<'a, M> = if picture.mood == Mood::NoLink {
+ container(
+ column![
+ text("Z z").font(PIXEL).size(14.0 * S).color(theme::MIST),
+ sized(&sprites::KORE_SLEEP, 6, true, 6.0 * S),
+ text("NO LINK").font(PIXEL).size(typography::BOX_STATE.size * S).color(theme::MIST),
+ text("waiting for the Mac").font(PIXEL).size(11.0 * S).color(theme::MIST_TEXT),
+ ]
+ .spacing(6.0 * S)
+ .align_x(Alignment::Center),
+ )
+ .center(Length::Fill)
+ .style(|_| container::Style {
+ background: Some(theme::BOX_GRAY_GROUND.into()),
+ border: Border { radius: INNER.into(), ..Border::default() },
+ ..container::Style::default()
+ })
+ .into()
+ } else {
+ let mood = picture.mood;
+ let side = container(
+ column![
+ sized(kore(mood), 4, false, 4.0 * S),
+ text(mood_word(mood)).font(PIXEL).size(16.0 * S).color(mood_color(mood)),
+ ]
+ .spacing(8.0 * S)
+ .align_x(Alignment::Center),
+ )
+ .center_y(Length::Fill)
+ .center_x(Length::Fixed(96.0 * S))
+ .style(|_| container::Style {
+ background: Some(theme::BOX_CARD.into()),
+ border: Border { radius: iced::border::Radius::default().left(INNER), ..Border::default() },
+ ..container::Style::default()
+ });
+ let cards: Element<'a, M> = if picture.tickets.is_empty() {
+ container(
+ column![
+ text("ALL QUIET").font(PIXEL).size(12.0 * S).color(theme::BOX_TEXT),
+ text(format!(
+ "{} done · {} asks · {} busy",
+ picture.today.done,
+ picture.today.asks,
+ super::picture::busy(picture.today.busy_seconds).to_uppercase()
+ ))
+ .font(PIXEL)
+ .size(10.0 * S)
+ .color(theme::BOX_TEXT_MUTED),
+ ]
+ .spacing(6.0 * S)
+ .align_x(Alignment::Center),
+ )
+ .center(Length::Fill)
+ .into()
+ } else {
+ column(picture.tickets.iter().map(|ticket| {
+ container(
+ row![
+ sized(tile(&format!("icon-{}", ticket.icon)), 2, false, 2.0 * S),
+ text(ticket.title.clone()).font(PIXEL).size(13.0 * S).color(theme::BOX_TEXT).wrapping(text::Wrapping::None),
+ ]
+ .spacing(8.0 * S)
+ .align_y(Alignment::Center),
+ )
+ .padding(7.0 * S)
+ .width(Length::Fill)
+ .clip(true)
+ .style(|_| container::Style {
+ background: Some(theme::BOX_CARD.into()),
+ border: Border { radius: (8.0 * S).into(), ..Border::default() },
+ ..container::Style::default()
+ })
+ .into()
+ }))
+ .spacing(8.0 * S)
+ .padding(Padding::from([10.0 * S, 8.0 * S]))
+ .into()
+ };
+ container(row![side, cards])
+ .style(|_| container::Style {
+ background: Some(theme::BOX_GROUND.into()),
+ border: Border { radius: INNER.into(), ..Border::default() },
+ ..container::Style::default()
+ })
+ .into()
+ };
+ container(container(screen).width(208.0).height(156.0).clip(true))
+ .padding(theme::STROKE_BOLD)
+ .style(|_| sticker(theme::INK, theme::RADIUS_MD, theme::STROKE_BOLD, theme::STICKER_SM))
+ .into()
+}
+
+fn sidebar<'a>(picture: &BoxPicture, sections: &[Section], current: Section, app_version: &str) -> Element<'a, Pick> {
+ let link_line = if !picture.daemon_alive {
+ tr("DAEMON NOT RUNNING", &[])
+ } else if let Some(link) = picture.link {
+ tr("BOX ONLINE · %@", &[&link.label()])
+ } else {
+ tr("NO BOX LINKED", &[])
+ };
+ let linked = picture.link.is_some();
+ let dot = container(Space::new())
+ .width(8)
+ .height(8)
+ .style(move |_| container::Style {
+ background: Some((if linked { theme::MINT } else { theme::MIST }).into()),
+ border: Border { color: theme::INK, width: theme::STROKE_THIN, radius: 0.0.into() },
+ ..container::Style::default()
+ });
+ let status = column![
+ row![dot, text(link_line).font(PIXEL).size(12).color(theme::INK)].spacing(6).align_y(Alignment::Center),
+ text(tr("%lld done · %lld asks today", &[&picture.today.done, &picture.today.asks])).font(PIXEL).size(12).color(theme::MUTED),
+ ]
+ .spacing(2)
+ .padding(Padding::from([0, 4]));
+ let nav = column(sections.iter().map(|§ion| {
+ let on = section == current;
+ button(
+ row![sprite(tile(section.tile()), 2, false), text(section.title()).font(rounded(iced::font::Weight::ExtraBold)).size(15)]
+ .spacing(10)
+ .align_y(Alignment::Center),
+ )
+ .padding(Padding::from([6, 8]))
+ .width(Length::Fill)
+ .on_press(Pick(section))
+ .style(move |_, _| button::Style {
+ background: on.then_some(Background::Color(theme::INK)),
+ text_color: if on { theme::CREAM } else { theme::INK },
+ border: Border { radius: 12.0.into(), ..Border::default() },
+ ..button::Style::default()
+ })
+ .into()
+ }))
+ .spacing(4);
+ let version = match &picture.firmware_version {
+ Some(firmware) => tr("app %@ · box fw %@", &[&app_version, firmware]),
+ None => tr("app %@", &[&app_version]),
+ };
+ let content = column![
+ column![
+ text("korekore").font(rounded(iced::font::Weight::ExtraBold)).size(26).color(theme::INK),
+ container(sprite(&sprites::TAG_YOUR_VIBEBUDDY, 2, false)).padding(Padding::ZERO.top(4)),
+ ]
+ .spacing(2)
+ .padding(Padding::from([0, 2])),
+ container(mini_box(picture)).center_x(Length::Fill),
+ status,
+ nav,
+ Space::new().height(Length::Fill),
+ text(version).font(PIXEL).size(12).color(theme::MUTED_2),
+ ]
+ .spacing(14);
+ container(container(content).padding(Padding { top: 14.0, right: 14.0, bottom: 16.0, left: 14.0 }))
+ .width(240.0 - theme::STROKE_BOLD)
+ .height(Length::Fill)
+ .style(|_| container::Style { background: Some(theme::PAPER.into()), ..container::Style::default() })
+ .into()
+}
+
+/// The whole window: sidebar, its 3 px ink edge, and the page.
+pub fn settings<'a>(
+ picture: &BoxPicture,
+ sections: &[Section],
+ current: Section,
+ app_version: &str,
+ page: Element<'a, Pick>,
+) -> Element<'a, Pick> {
+ let edge = container(Space::new()).width(theme::STROKE_BOLD).height(Length::Fill).style(|_| container::Style {
+ background: Some(theme::INK.into()),
+ ..container::Style::default()
+ });
+ let title = row![
+ text(current.title()).font(typography::TITLE.font).size(typography::TITLE.size).color(theme::INK),
+ Space::new().width(Length::Fill),
+ text(current.subtitle()).font(rounded(iced::font::Weight::Normal)).size(13).color(theme::MUTED),
+ ]
+ .align_y(Alignment::End);
+ let main = container(column![title, page].spacing(14))
+ .padding(Padding { top: 22.0, right: 22.0, bottom: 20.0, left: 22.0 })
+ .width(Length::Fill)
+ .height(Length::Fill);
+ container(row![sidebar(picture, sections, current, app_version), edge, main])
+ .width(Length::Fill)
+ .height(Length::Fill)
+ .style(|_| container::Style { background: Some(theme::CREAM.into()), ..container::Style::default() })
+ .into()
+}
+
+/// Stands in for a page that comes with a later step of the korekore UI.
+pub fn pending<'a>() -> Element<'a, Pick> {
+ container(text(tr("This page comes with the next step of korekore.", &[])).font(rounded(iced::font::Weight::Normal)).size(14).color(theme::MUTED))
+ .center_x(Length::Fill)
+ .center_y(Length::Fixed(120.0))
+ .style(|_| container::Style {
+ border: Border { color: theme::INK, width: theme::STROKE_BASE, radius: theme::RADIUS_MD.into() },
+ ..container::Style::default()
+ })
+ .into()
+}
diff --git a/desktop/src/main.rs b/desktop/src/main.rs
index 59c31ce5..51ed8690 100644
--- a/desktop/src/main.rs
+++ b/desktop/src/main.rs
@@ -221,6 +221,8 @@ enum Message {
OwnKeySaved(client::KeyProvider, Result<(), String>),
Done(Result<(), String>),
Quit,
+ /// A section picked in the korekore UI's sidebar (ADR-0021).
+ Korekore(korekore::view::Pick),
}
#[cfg(target_os = "linux")]
@@ -326,6 +328,9 @@ struct App {
/// VibeBuddy's Omarchy plugin is in the bar, showing the face; the tray icon would be a second one.
#[cfg(target_os = "linux")]
plugin: bool,
+ /// The korekore UI's section, when it was asked for at launch (`VIBEBUDDY_UI=korekore`, ADR-0021); None keeps
+ /// the current UI.
+ korekore: Option,
}
/// Making a Character: the preset it starts from, its lines as edited (occasions stay, each keeps a line), the voice,
@@ -404,6 +409,7 @@ impl App {
tray: None,
#[cfg(target_os = "linux")]
plugin: plugin_in_omarchy_bar(),
+ korekore: korekore::requested().then_some(korekore::Section::Character),
};
// The first launch shows the window, so it's clear where the app went; later ones stay in the tray, unless a
// language change restarted the app from its window. Without a tray (macOS builds, for development) the
@@ -553,10 +559,16 @@ impl App {
if let Some(id) = self.settings {
return window::gain_focus(id);
}
- // The same as the Mac's: room for the whole Character tab, resizable down to the old size.
+ // The same as the Mac's: room for the whole Character tab, resizable down to the old size. The
+ // korekore window is the design's 960 × 772, down to 900 × 760.
+ let (size, min_size) = if self.korekore.is_some() {
+ (iced::Size::new(960.0, 772.0), iced::Size::new(900.0, 760.0))
+ } else {
+ (iced::Size::new(760.0, 640.0), iced::Size::new(640.0, 480.0))
+ };
let (id, open) = window::open(window::Settings {
- size: iced::Size::new(760.0, 640.0),
- min_size: Some(iced::Size::new(640.0, 480.0)),
+ size,
+ min_size: Some(min_size),
#[cfg(target_os = "linux")]
platform_specific: window::settings::PlatformSpecific {
application_id: "vibebuddy".to_owned(),
@@ -1163,6 +1175,7 @@ impl App {
}
}
Message::Quit => return iced::exit(),
+ Message::Korekore(korekore::view::Pick(section)) => self.korekore = Some(section),
}
Task::none()
}
@@ -1199,12 +1212,14 @@ impl App {
#[cfg(target_os = "linux")]
if let Some(TrayHandle(handle)) = self.tray.clone() {
let menu = MenuState::derive(self.status.as_ref());
+ let kore = self.korekore.map(|_| korekore::picture::BoxPicture::derive(self.status.as_ref()));
let [r, g, b, _] = self.theme.palette().text.into_rgba8();
return Task::future(async move {
handle
.update(move |tray| {
tray.menu = menu;
tray.color = [r, g, b];
+ tray.kore = kore;
})
.await;
})
@@ -1214,6 +1229,9 @@ impl App {
}
fn view(&self, _window: window::Id) -> Element<'_, Message> {
+ if let Some(section) = self.korekore {
+ return self.korekore(section);
+ }
if let Some(page) = self.guide {
return self.guide(page);
}
@@ -1253,6 +1271,18 @@ impl App {
container(content).width(Length::Fill).height(Length::Fill).into()
}
+ /// The korekore settings window (ADR-0021); its pages come in the next steps.
+ fn korekore(&self, section: korekore::Section) -> Element<'_, Message> {
+ // Voice commands are experimental: no Conversation section while the daemon has them off.
+ let sections: Vec = korekore::Section::ALL
+ .into_iter()
+ .filter(|§ion| section != korekore::Section::Conversation || self.resident)
+ .collect();
+ let picture = korekore::picture::BoxPicture::derive(self.status.as_ref());
+ korekore::view::settings(&picture, §ions, section, env!("CARGO_PKG_VERSION"), korekore::view::pending())
+ .map(Message::Korekore)
+ }
+
/// First run on Linux: what the install script couldn't do. It already connected the agents and set up the start
/// at login, so the guide is the Mac's without those pages: find the box, pick a character, put the box on Wi-Fi.
fn guide(&self, page: usize) -> Element<'_, Message> {
@@ -2836,7 +2866,8 @@ fn run_tray() -> impl futures::Stream- {
iced::stream::channel(16, async |mut output| {
let (events, mut clicks) = futures::channel::mpsc::unbounded();
- let tray = tray::Tray { menu: MenuState::derive(None), color: [255, 255, 255], events };
+ let kore = korekore::requested().then(|| korekore::picture::BoxPicture::derive(None));
+ let tray = tray::Tray { menu: MenuState::derive(None), kore, color: [255, 255, 255], events };
// At login the app can start before the shell's tray host is up; ksni then registers once it appears, and
// again after the shell restarts. The app opens the window if it doesn't (`Message::TrayHost`).
match tray.assume_sni_available(true).spawn().await {
diff --git a/desktop/src/status.rs b/desktop/src/status.rs
index f2422e49..fc88eb0d 100644
--- a/desktop/src/status.rs
+++ b/desktop/src/status.rs
@@ -16,6 +16,8 @@ pub struct Status {
pub config: Config,
/// What the update manifest offers (ADR-0010); absent from older daemons.
pub updates: Option
,
+ /// Every Activity, newest first, as the box's task cards show them; empty from older daemons.
+ pub activities: Vec,
/// The VibeBuddy account this computer is signed in to (ADR-0018); older daemons don't say, which reads as not
/// available.
pub account: Account,
@@ -106,6 +108,19 @@ pub struct Usage {
pub commands_limit: u64,
}
+/// One Activity as the daemon summarises it (daemon/src/activity.rs `ActivitySummary`).
+#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
+#[serde(default)]
+pub struct Activity {
+ /// `codex`, `claude-code`, `github-actions`, or the agent a CLI hook names (`opencode`, `copilot`, `pi`).
+ pub agent: String,
+ /// The task card's title, with the prefix that tells agents apart (`CC:`, `CX:`…).
+ pub title: String,
+ pub project: Option,
+ /// `working` or `needs_input`.
+ pub state: String,
+}
+
/// The daemon's view of updates; which firmware to offer is its call, as on the Mac.
#[derive(Clone, Debug, Default, Deserialize, PartialEq)]
#[serde(default)]
diff --git a/desktop/src/tray.rs b/desktop/src/tray.rs
index fa44c4c7..1feda1ef 100644
--- a/desktop/src/tray.rs
+++ b/desktop/src/tray.rs
@@ -27,6 +27,8 @@ pub enum TrayEvent {
pub struct Tray {
pub menu: MenuState,
+ /// With the korekore UI on (ADR-0021) the icon is Kore, drawn from this; None keeps the face.
+ pub kore: Option,
/// The face is drawn in the theme's text color so it reads on the bar.
pub color: [u8; 3],
pub events: UnboundedSender,
@@ -42,6 +44,15 @@ impl ksni::Tray for Tray {
}
fn icon_pixmap(&self) -> Vec {
+ if let Some(picture) = &self.kore {
+ return [1, 2]
+ .into_iter()
+ .map(|scale| {
+ let (width, height, data) = crate::korekore::tray_icon(picture, scale);
+ Icon { width: width as i32, height: height as i32, data }
+ })
+ .collect();
+ }
let [r, g, b] = self.color;
// A dimmed, eyes-closed face when the box or the daemon is gone, as on the Mac.
let (eyes_closed, alpha) = match self.menu.icon {
diff --git a/docs/app.md b/docs/app.md
index 29e96334..0ec3f3e3 100644
--- a/docs/app.md
+++ b/docs/app.md
@@ -28,7 +28,7 @@ The app is VibeBuddy's graphical interface on the Mac: a menu bar icon plus a se
It keeps using local HTTP on `127.0.0.1:7331`. New additions:
-- `GET /v1/status`: link, current mode, firmware and daemon build identifiers, today's stats, and the time of the most recent hook event.
+- `GET /v1/status`: link, current mode, firmware and daemon build identifiers, today's stats, the live Activities (`activities`: agent, task-card title, project, `working`/`needs_input`, newest first, as the resident session's `list_activities` gives them; the korekore sidebar draws the box from them), and the time of the most recent hook event.
- `GET /v1/status/stream`: SSE, pushed whenever the status changes.
- `GET /v1/resident` and `GET /v1/resident/history`: whether the resident session is on and ready (Codex installed and signed in, or Claude Code installed with the user's Anthropic API key), which Agent answers, a change marker for its history, and its last exchanges, for the Conversation tab; `PUT /v1/resident/brain` and `PUT`/`DELETE /v1/resident/anthropic-key` for its settings, which are kept out of `/v1/config` (the key is never sent back).
- `POST /v1/account/sign-in`, `POST /v1/account/sign-out`, `POST /v1/account/refresh` (today's use and the plan) and `DELETE /v1/account` for the VibeBuddy account (ADR-0018). Signing in answers a URL for the App to open in the browser, which comes back to the daemon's own `/v1/account/callback`; the session is the daemon's (`config_dir()/account`) and never reaches the App, which learns of the result from `account` in the status stream.
diff --git a/docs/design/korekore/README.md b/docs/design/korekore/README.md
index 1b25580b..875cc3d5 100644
--- a/docs/design/korekore/README.md
+++ b/docs/design/korekore/README.md
@@ -145,6 +145,8 @@ The current UI (settings window, onboarding, menu bar item, box screens) is fini
- **Mac menu bar**: today `NSStatusItem` + `NSMenu` (`AppDelegate.swift` l.35, rebuilt l.124-152 from `VibeBuddyCore/MenuState.swift`). With the switch on, use Kore as the icon and a `MenuBarExtra(.window)` panel fed by the same `MenuState`.
- **Linux**, new module `desktop/src/korekore/`: a sidebar view and one function per section, mirroring the current `enum Tab` (`main.rs` l.67) and section methods, but in new code; the first-run guide likewise (current `guide()` ~l.905). With the switch on, `view()` (l.877) delegates to it. The korekore UI ignores `theme.rs`/Omarchy and uses its own palette; the current UI keeps following Omarchy.
- **Kore in the tray and menu bar**: new sprite code; leave `PixelFace.swift` and `desktop/src/face.rs` as they are for the current UI.
+- Done in 2a (behind `VIBEBUDDY_UI=korekore`): the window, the sidebar with the mini box, the menu bar item and its panel, and Kore in the Linux tray. The mini box reads the Activities `/v1/status` now carries. The Mac views live in `app/Sources/KorekoreUI` and are drawn with sample data by `swift run --package-path app KorekorePreview `; the Linux ones by `cargo test -p vibebuddy-desktop korekore::snapshots -- --ignored` (into `target/korekore-preview`). Screenshots: `screenshots/phase-2a/`.
+- **Linux differs** in two places: the tray keeps its StatusNotifierItem menu, since a tray host draws that menu itself and has no room for the Mac's panel (the icon is Kore, with the same `sun` dot and gray); and Omarchy draws the window's frame, so the view is the inside of the design's window, without its outline, shadow or window buttons.
### Phase 3: the korekore box
diff --git a/docs/design/korekore/screenshots/phase-2a/linux-mini-box.png b/docs/design/korekore/screenshots/phase-2a/linux-mini-box.png
new file mode 100644
index 00000000..3d2145df
Binary files /dev/null and b/docs/design/korekore/screenshots/phase-2a/linux-mini-box.png differ
diff --git a/docs/design/korekore/screenshots/phase-2a/linux-window.png b/docs/design/korekore/screenshots/phase-2a/linux-window.png
new file mode 100644
index 00000000..96b10b1c
Binary files /dev/null and b/docs/design/korekore/screenshots/phase-2a/linux-window.png differ
diff --git a/docs/design/korekore/screenshots/phase-2a/mac-menu-icons.png b/docs/design/korekore/screenshots/phase-2a/mac-menu-icons.png
new file mode 100644
index 00000000..35468204
Binary files /dev/null and b/docs/design/korekore/screenshots/phase-2a/mac-menu-icons.png differ
diff --git a/docs/design/korekore/screenshots/phase-2a/mac-menu-panel-no-link.png b/docs/design/korekore/screenshots/phase-2a/mac-menu-panel-no-link.png
new file mode 100644
index 00000000..e82183eb
Binary files /dev/null and b/docs/design/korekore/screenshots/phase-2a/mac-menu-panel-no-link.png differ
diff --git a/docs/design/korekore/screenshots/phase-2a/mac-menu-panel.png b/docs/design/korekore/screenshots/phase-2a/mac-menu-panel.png
new file mode 100644
index 00000000..5240ca77
Binary files /dev/null and b/docs/design/korekore/screenshots/phase-2a/mac-menu-panel.png differ
diff --git a/docs/design/korekore/screenshots/phase-2a/mac-mini-box.png b/docs/design/korekore/screenshots/phase-2a/mac-mini-box.png
new file mode 100644
index 00000000..32d1bbdc
Binary files /dev/null and b/docs/design/korekore/screenshots/phase-2a/mac-mini-box.png differ
diff --git a/docs/design/korekore/screenshots/phase-2a/mac-window.png b/docs/design/korekore/screenshots/phase-2a/mac-window.png
new file mode 100644
index 00000000..b3750962
Binary files /dev/null and b/docs/design/korekore/screenshots/phase-2a/mac-window.png differ
diff --git a/tools/check-localization.py b/tools/check-localization.py
index 5c81df5b..dd6dca3c 100755
--- a/tools/check-localization.py
+++ b/tools/check-localization.py
@@ -28,7 +28,7 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
-SOURCES = [ROOT / "app/Sources/VibeBuddy", ROOT / "app/Sources/VibeBuddyCore"]
+SOURCES = [ROOT / "app/Sources/VibeBuddy", ROOT / "app/Sources/VibeBuddyCore", ROOT / "app/Sources/KorekoreUI"]
RUST_SOURCES = ROOT / "desktop/src"
TABLE = ROOT / "app/Localization/zh-Hans.lproj/Localizable.strings"
@@ -134,7 +134,7 @@ def source_keys() -> dict[str, str]:
continue
line = code.count("\n", 0, offset) + 1
keys.setdefault(text, f"{path.relative_to(ROOT)}:{line}")
- for path in sorted(RUST_SOURCES.glob("*.rs")):
+ for path in sorted(RUST_SOURCES.rglob("*.rs")):
code = path.read_text(encoding="utf-8")
for match in re.finditer(r'(?> 16 & 0xFF) / 255, green: Double(rgb >> 8 & 0xFF) / 255, "
"blue: Double(rgb & 0xFF) / 255)",
" }", "}", ""]
@@ -289,7 +289,7 @@ def outputs():
tokens = load("tokens.json")
all_sprites = sprites(tokens)
return {
- "app/Sources/VibeBuddy/Korekore/KorekoreTheme.swift": swift_theme(tokens),
+ "app/Sources/KorekoreUI/KorekoreTheme.swift": swift_theme(tokens),
"app/Sources/VibeBuddyCore/Korekore/KorekoreSprites.swift": swift_sprites(all_sprites),
"desktop/src/korekore/theme.rs": rust_theme(tokens),
"desktop/src/korekore/sprites.rs": rust_sprites(all_sprites, firmware=False),