Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions app/Localization/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "登录";
Expand Down
7 changes: 6 additions & 1 deletion app/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,20 @@ 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"])]
),
// 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 <dir>`.
.executableTarget(name: "KorekorePreview", dependencies: ["KorekoreUI"], path: "Sources/KorekorePreview"),
]
)
95 changes: 95 additions & 0 deletions app/Sources/KorekorePreview/main.swift
Original file line number Diff line number Diff line change
@@ -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 <output dir> [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 <output dir> [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<V: View>(_ 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()
}
112 changes: 112 additions & 0 deletions app/Sources/KorekoreUI/Components.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<CFError>?
Expand All @@ -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),
Expand Down
Loading
Loading