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),