feat: System Settings redesign + Notch theme reset + Buddy style picker

Settings window (SystemSettingsView):
- Graphite dark palette (#201f27 sidebar / #1c1c1e detail) replacing the
  lime L-shape, per Claude Design reference bundle. Lime survives only
  as an accent on toggles, icons, and active pills.
- macOS-style titlebar with real traffic lights (red close / yellow
  hide / green decorative) + centered "系统设置".
- Tabs get large H1 + English subtitle ("通用  General preferences").
- New primitives: SectionLabel, SettingsListCard, SettingRow
  (icon tile + label + sublabel + control), InfoRow (pos/neg/hint
  colored dots), IOSToggle (pill slider).
- General tab rewritten: stacked rows with dividers, 3 toggles with
  sublabels, proxy card with ✓/✕/i info rows, language Menu picker,
  accessibility status row.
- Bottom sidebar "返回" → "退出" calling NSApplication.terminate.
- Window resized 720×560 → 960×720 (1.33:1) for reference-mock breathing
  room. Still fixed-size (borderless).
- TextField placeholder: ZStack overlay with solid light gray, since
  SwiftUI TextField.prompt ignores foregroundColor on macOS. Applied to
  both the Anthropic proxy field and the Install-from-URL field.

Notch themes (NotchTheme / NotchCustomization):
- Reset to 7 themes: Classic + Forest / Neon Tokyo / Sunset /
  Retro Arcade / High Contrast / Sakura (ported from Claude Design
  themes.jsx palettes). Dropped: paper, neonLime, cyber, mint, rosegold,
  ocean, aurora, mocha, lavender, cherry.
- Graceful decode: try? c.decode(NotchThemeID) so legacy raw values
  fall back to .classic instead of throwing.
- NotchPalette gains `accent` field. NotchView.statusDotColor and
  badgeColor use accent for .idle, so at-rest notch reflects the theme
  instead of hardcoded 30%-white (invisible on light-bg themes).
- Theme picker replaced Menu dropdown with 2-column grid of preview
  cards, each rendering a mini pill in that theme's own colors. Selected
  card borders/glows in its own accent.

Buddy style (NotchCustomization.BuddyStyle):
- New `buddyStyle: BuddyStyle` field with two cases: .pixelCat, .emoji.
  Evaluated a .neon option via NeonPixelCatView but it degrades to a
  green blob at 16×16; pulled from the picker pending a small-size
  renderer.
- Migration: missing buddyStyle decodes by reading the legacy
  usePixelCat AppStorage bool. Picker writes back to usePixelCat so
  unmigrated call sites (ClaudeInstancesView) stay in sync.
- Old "Pixel Cat Mode" toggle removed from Appearance tab — the new
  segmented picker in the Notch section supersedes it.

Plugin panel size hint for built-ins:
- LoadedPlugin.preferredPanelSize checks an @objc runtime method first,
  then falls back to Info.plist (gated on bundle !== Bundle.main so
  built-ins don't accidentally read the host's plist).
- PairPhonePlugin declares @objc preferredPanelSize() = 340×480, then
  bumped to 440×480 to match the music plugin's width.

Tests:
- NotchThemeTests / NotchCustomizationTests / NotchCustomizationStoreTests
  updated to new theme line-up plus a regression guard for the legacy
  theme ID → .classic fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
徐翔宇 2026-04-20 11:25:15 +08:00
parent daae3ecba0
commit b2f3a3554f
12 changed files with 1087 additions and 312 deletions

View file

@ -146,6 +146,10 @@ enum L10n {
static var back: String { tr("Back", "返回") }
static var groupByProject: String { tr("Group by Project", "按项目分组") }
static var pixelCatMode: String { tr("Pixel Cat Mode", "像素猫模式") }
static var notchBuddyStyle: String { tr("Buddy Style", "Buddy 样式") }
static var notchBuddyPixelCat: String { tr("Cat", "像素猫") }
static var notchBuddyEmoji: String { tr("Emoji", "Emoji") }
static var notchBuddyNeon: String { tr("Neon", "霓虹") }
static var launchAtLogin: String { tr("Launch at Login", "开机启动") }
static var hooks: String { tr("Hooks", "钩子") }
static var codexSupport: String { tr("Codex Support", "Codex 支持") }
@ -443,18 +447,16 @@ enum L10n {
static var notchSectionHeader: String { tr("Notch", "灵动岛") }
static var notchTheme: String { tr("Theme", "主题") }
// v2 theme line-up (2026-04-20): Classic + six themes designed via
// Claude Design. Old names (paper, neonLime, cyber, mint, rosegold,
// ocean, aurora, mocha, lavender, cherry) were dropped on reset.
static var notchThemeClassic: String { tr("Classic", "经典") }
static var notchThemePaper: String { tr("Paper", "纸张") }
static var notchThemeNeonLime: String { tr("Neon Lime", "霓虹青柠") }
static var notchThemeCyber: String { tr("Cyber", "赛博") }
static var notchThemeMint: String { tr("Mint", "薄荷") }
static var notchThemeSunset: String { tr("Sunset", "日落") }
static var notchThemeRosegold: String { tr("Rosé Gold", "玫瑰金") }
static var notchThemeOcean: String { tr("Ocean", "深海") }
static var notchThemeAurora: String { tr("Aurora", "极光") }
static var notchThemeMocha: String { tr("Mocha", "摩卡") }
static var notchThemeLavender: String { tr("Lavender", "薰衣草") }
static var notchThemeCherry: String { tr("Cherry", "樱桃") }
static var notchThemeForest: String { tr("Forest", "森林") }
static var notchThemeNeonTokyo: String { tr("Neon Tokyo", "霓虹东京") }
static var notchThemeSunset: String { tr("Sunset", "落日") }
static var notchThemeRetroArcade: String { tr("Retro Arcade", "复古游戏机") }
static var notchThemeHighContrast: String { tr("High Contrast", "高对比") }
static var notchThemeSakura: String { tr("Sakura", "樱花") }
static var notchHoverSpeed: String { tr("Hover Speed", "展开速度") }
static var notchHoverInstant: String { tr("Fast", "即时") }
static var notchHoverNormal: String { tr("1s", "1秒") }
@ -482,18 +484,13 @@ enum L10n {
static var notchEditPresetDisabledTooltip: String { tr("Your device doesn't have a hardware notch", "你的设备没有硬件刘海") }
static func notchThemeName(_ id: NotchThemeID) -> String {
switch id {
case .classic: return notchThemeClassic
case .paper: return notchThemePaper
case .neonLime: return notchThemeNeonLime
case .cyber: return notchThemeCyber
case .mint: return notchThemeMint
case .sunset: return notchThemeSunset
case .rosegold: return notchThemeRosegold
case .ocean: return notchThemeOcean
case .aurora: return notchThemeAurora
case .mocha: return notchThemeMocha
case .lavender: return notchThemeLavender
case .cherry: return notchThemeCherry
case .classic: return notchThemeClassic
case .forest: return notchThemeForest
case .neonTokyo: return notchThemeNeonTokyo
case .sunset: return notchThemeSunset
case .retroArcade: return notchThemeRetroArcade
case .highContrast: return notchThemeHighContrast
case .sakura: return notchThemeSakura
}
}
}

View file

@ -26,6 +26,7 @@ struct NotchCustomization: Codable, Equatable {
// Appearance
var theme: NotchThemeID
var fontScale: FontScale
var buddyStyle: BuddyStyle
// Visibility toggles
var showBuddy: Bool
@ -44,6 +45,7 @@ struct NotchCustomization: Codable, Equatable {
init(
theme: NotchThemeID = .classic,
fontScale: FontScale = .default,
buddyStyle: BuddyStyle = .pixelCat,
showBuddy: Bool = true,
showUsageBar: Bool = true,
hardwareNotchMode: HardwareNotchMode = .auto,
@ -51,6 +53,7 @@ struct NotchCustomization: Codable, Equatable {
) {
self.theme = theme
self.fontScale = fontScale
self.buddyStyle = buddyStyle
self.showBuddy = showBuddy
self.showUsageBar = showUsageBar
self.hardwareNotchMode = hardwareNotchMode
@ -80,15 +83,28 @@ struct NotchCustomization: Codable, Equatable {
// for a Mac app shipping value types to user defaults.)
private enum CodingKeys: String, CodingKey {
case theme, fontScale, showBuddy, showUsageBar,
case theme, fontScale, buddyStyle, showBuddy, showUsageBar,
hardwareNotchMode, hoverSpeed, screenGeometries, defaultGeometry,
maxWidth, horizontalOffset // legacy keys for migration
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.theme = try c.decodeIfPresent(NotchThemeID.self, forKey: .theme) ?? .classic
// `try?` because `decodeIfPresent` THROWS when the key exists but the
// raw value isn't a case of NotchThemeID which happens every time
// we rename or drop themes (v1 v2 shipped with 11 renames).
self.theme = (try? c.decode(NotchThemeID.self, forKey: .theme)) ?? .classic
self.fontScale = try c.decodeIfPresent(FontScale.self, forKey: .fontScale) ?? .default
// Buddy style is new (v1.1). If absent from the persisted blob, fall
// back to the legacy `usePixelCat` AppStorage bool so existing users
// see what they saw before the picker shipped.
if let decoded = try? c.decode(BuddyStyle.self, forKey: .buddyStyle) {
self.buddyStyle = decoded
} else if UserDefaults.standard.object(forKey: "usePixelCat") != nil {
self.buddyStyle = UserDefaults.standard.bool(forKey: "usePixelCat") ? .pixelCat : .emoji
} else {
self.buddyStyle = .pixelCat
}
self.showBuddy = try c.decodeIfPresent(Bool.self, forKey: .showBuddy) ?? true
self.showUsageBar = try c.decodeIfPresent(Bool.self, forKey: .showUsageBar) ?? true
self.hardwareNotchMode = try c.decodeIfPresent(HardwareNotchMode.self, forKey: .hardwareNotchMode) ?? .auto
@ -116,31 +132,49 @@ struct NotchCustomization: Codable, Equatable {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(theme, forKey: .theme)
try c.encode(fontScale, forKey: .fontScale)
try c.encode(buddyStyle, forKey: .buddyStyle)
try c.encode(showBuddy, forKey: .showBuddy)
try c.encode(showUsageBar, forKey: .showUsageBar)
try c.encode(hardwareNotchMode, forKey: .hardwareNotchMode)
try c.encode(hoverSpeed, forKey: .hoverSpeed)
try c.encode(screenGeometries, forKey: .screenGeometries)
try c.encode(defaultGeometry, forKey: .defaultGeometry)
}
}
/// Identifier for one of the six built-in themes. Raw string values
/// Which sprite sits next to the status dot in the notch pill.
/// - `pixelCat`: the 13×11 hand-painted tabby from `PixelCharacterView`.
/// Reacts to 6 animation states (idle/working/needsYou/).
/// - `emoji`: Claude Code companion emoji from `~/.claude.json`
/// (18 species duck/cat/owl/). Falls back to `pixelCat` when no
/// companion data exists.
///
/// NOTE: `neon` was considered (cyberpunk recolor of the pixel cat with
/// glow + hue wave) but `NeonPixelCatView` is designed for the full-size
/// loading screen and collapses into a green blob at the notch's 16×16
/// target. Pulled it from the picker rather than ship broken visuals.
enum BuddyStyle: String, Codable, CaseIterable, Identifiable {
case pixelCat
case emoji
var id: String { rawValue }
}
/// Identifier for one of the built-in themes. Raw string values
/// so persisted JSON is stable across code renames.
///
/// v2 line-up (2026-04-20): reset to `classic` + six themes designed
/// via Claude Design (island/project/themes.jsx). Older raw values
/// persisted from the v1 palette ("paper", "cyber", "mint", etc.) fall
/// back to `.classic` on decode see NotchCustomization.init(from:).
enum NotchThemeID: String, Codable, CaseIterable, Identifiable {
// Free themes
case classic
case paper
case neonLime
case cyber
case mint
case forest
case neonTokyo
case sunset
// Premium themes
case rosegold
case ocean
case aurora
case mocha
case lavender
case cherry
case retroArcade
case highContrast
case sakura
var id: String { rawValue }
}

View file

@ -2,7 +2,7 @@
// NotchTheme.swift
// ClaudeIsland
//
// Palette definitions for the six built-in notch themes. Palette
// Palette definitions for the built-in notch themes. Palette
// colors drive the notch background, primary foreground (text,
// icons), and the dimmer secondary foreground (timestamps,
// percentage indicators). Status colors (success / warning / error)
@ -10,8 +10,12 @@
// semantic meaning across themes and live in Assets.xcassets
// under NotchStatus/.
//
// Spec: docs/superpowers/specs/2026-04-08-notch-customization-design.md
// section 5.3.
// v2 line-up (2026-04-20): Classic + six themes designed via
// Claude Design (see /tmp/codeisland-themes/island/project/themes.jsx
// for the full spec including per-state dots, corner SVGs, and
// custom fonts not all of which the NotchPalette 3-field shape
// expresses today). The 3 fields below ARE the safe subset that
// every call site already consumes.
//
import SwiftUI
@ -20,10 +24,14 @@ struct NotchPalette: Equatable {
let bg: Color
let fg: Color
let secondaryFg: Color
/// Signature tint for idle-state dots, buddy highlights, and theme
/// preview swatches. NOT used for semantic status (red/amber/green for
/// error/attention/success) those stay universal across themes.
let accent: Color
}
extension NotchPalette {
/// Lookup the palette for a given theme ID. All six cases are
/// Lookup the palette for a given theme ID. All cases are
/// defined inline so adding a theme means touching exactly one
/// switch statement.
static func `for`(_ id: NotchThemeID) -> NotchPalette {
@ -32,43 +40,50 @@ extension NotchPalette {
return NotchPalette(
bg: .black,
fg: .white,
secondaryFg: Color(white: 1, opacity: 0.4)
secondaryFg: Color(white: 1, opacity: 0.4),
accent: Color(hex: "CAFF00")
)
case .paper:
case .forest:
return NotchPalette(
bg: .white,
fg: .black,
secondaryFg: Color(white: 0, opacity: 0.55)
bg: Color(hex: "0d1f14"),
fg: Color(hex: "e8f5e9"),
secondaryFg: Color(hex: "8ba896"),
accent: Color(hex: "7cc85a")
)
case .neonLime:
case .neonTokyo:
return NotchPalette(
bg: Color(hex: "CAFF00"),
fg: .black,
secondaryFg: Color(white: 0, opacity: 0.55)
)
case .cyber:
return NotchPalette(
bg: Color(hex: "7C3AED"),
fg: Color(hex: "F0ABFC"),
secondaryFg: Color(hex: "C4B5FD")
)
case .mint:
return NotchPalette(
bg: Color(hex: "4ADE80"),
fg: .black,
secondaryFg: Color(white: 0, opacity: 0.55)
bg: Color(hex: "0a0520"),
fg: Color(hex: "f0e6ff"),
secondaryFg: Color(hex: "9a7ac8"),
accent: Color(hex: "ff2e97")
)
case .sunset:
return NotchPalette(
bg: Color(hex: "FB923C"),
fg: .black,
secondaryFg: Color(white: 0, opacity: 0.5)
bg: Color(hex: "fff4e8"),
fg: Color(hex: "4a2618"),
secondaryFg: Color(hex: "a06850"),
accent: Color(hex: "e8552a")
)
case .rosegold, .ocean, .aurora, .mocha, .lavender, .cherry:
case .retroArcade:
return NotchPalette(
bg: Color(hex: "c4cfa1"),
fg: Color(hex: "2a3018"),
secondaryFg: Color(hex: "5a6038"),
accent: Color(hex: "2a3018")
)
case .highContrast:
return NotchPalette(
bg: .black,
fg: .white,
secondaryFg: Color(white: 1, opacity: 0.4)
secondaryFg: .white,
accent: Color(hex: "ffe600")
)
case .sakura:
return NotchPalette(
bg: Color(hex: "fff0f3"),
fg: Color(hex: "6b2a3e"),
secondaryFg: Color(hex: "b07088"),
accent: Color(hex: "e66a88")
)
}
}
@ -80,18 +95,13 @@ extension NotchThemeID {
/// settings view so this file does not depend on L10n.
var displayName: String {
switch self {
case .classic: return "Classic"
case .paper: return "Paper"
case .neonLime: return "Neon Lime"
case .cyber: return "Cyber"
case .mint: return "Mint"
case .sunset: return "Sunset"
case .rosegold: return "Rose Gold"
case .ocean: return "Ocean"
case .aurora: return "Aurora"
case .mocha: return "Mocha"
case .lavender: return "Lavender"
case .cherry: return "Cherry"
case .classic: return "Classic"
case .forest: return "Forest"
case .neonTokyo: return "Neon Tokyo"
case .sunset: return "Sunset"
case .retroArcade: return "Retro Arcade"
case .highContrast: return "High Contrast"
case .sakura: return "Sakura"
}
}
}

View file

@ -26,6 +26,13 @@ final class PairPhonePlugin: NSObject, MioPlugin {
func makeView() -> NSView {
NSHostingView(rootView: PairPhonePluginView())
}
/// Matches the music plugin's panel width (440pt) so the expanded area is
/// consistent across built-ins and externals. Height trims the ~300pt of
/// empty space the default 780 left below the QR + pills stack.
@objc func preferredPanelSize() -> NSValue {
NSValue(size: NSSize(width: 440, height: 480))
}
}
/// Inline pairing panel no popup. Server config is shown prominently

View file

@ -77,17 +77,32 @@ final class NativePluginManager: ObservableObject {
return result?.takeUnretainedValue() as? NSView
}
/// Optional panel size hint from the plugin's Info.plist:
/// MioPluginPreferredWidth (Number, 280..1200)
/// MioPluginPreferredHeight (Number, 180..900)
/// Both must be present to take effect. Returns nil when either is
/// missing or out of bounds, letting the host fall back to its
/// default `(min(screenW*0.48, 620), min(screenH*0.78, 780))`.
/// Optional panel size hint. Two ways a plugin can provide one:
/// 1. Runtime ObjC method `@objc func preferredPanelSize() -> NSValue`
/// (required for built-in Swift plugins, since they share
/// `Bundle.main` with the host and can't carry their own plist)
/// 2. Info.plist keys `MioPluginPreferredWidth` / `MioPluginPreferredHeight`
/// (only consulted for external .bundle plugins reading them
/// from `Bundle.main` would return the host's values)
/// Returns nil when neither path yields a usable size, letting the
/// host fall back to its default `(min(screenW*0.48, 620), min(screenH*0.78, 780))`.
var preferredPanelSize: CGSize? {
guard
let info = bundle.infoDictionary,
let rawW = (info["MioPluginPreferredWidth"] as? NSNumber)?.doubleValue,
let rawH = (info["MioPluginPreferredHeight"] as? NSNumber)?.doubleValue
// Path 1: runtime selector
let sel = NSSelectorFromString("preferredPanelSize")
if instance.responds(to: sel),
let raw = instance.perform(sel)?.takeUnretainedValue() as? NSValue {
let size = raw.sizeValue
if size.width >= 280, size.width <= 1200,
size.height >= 180, size.height <= 900 {
return CGSize(width: size.width, height: size.height)
}
}
// Path 2: Info.plist only valid for external bundles
guard bundle !== Bundle.main,
let info = bundle.infoDictionary,
let rawW = (info["MioPluginPreferredWidth"] as? NSNumber)?.doubleValue,
let rawH = (info["MioPluginPreferredHeight"] as? NSNumber)?.doubleValue
else {
return nil
}

View file

@ -160,18 +160,29 @@ struct NativePluginStoreView: View {
.foregroundColor(.white.opacity(0.45))
HStack(spacing: 8) {
TextField("", text: $installURLText, prompt: Text("https://api.miomio.chat/api/i/...").foregroundColor(.white.opacity(0.3)))
.textFieldStyle(.plain)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.06)))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.disabled(urlInstalling)
// SwiftUI's TextField.prompt ignores `foregroundColor` on macOS,
// so we overlay our own placeholder Text in a solid light gray.
ZStack(alignment: .leading) {
if installURLText.isEmpty {
Text("https://api.miomio.chat/api/i/...")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.62, green: 0.62, blue: 0.65))
.padding(.horizontal, 10)
.allowsHitTesting(false)
}
TextField("", text: $installURLText)
.textFieldStyle(.plain)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.white.opacity(0.9))
.padding(.horizontal, 10)
.padding(.vertical, 8)
}
.background(RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.06)))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(Color.white.opacity(0.1), lineWidth: 1)
)
.disabled(urlInstalling)
Button {
if let str = NSPasteboard.general.string(forType: .string) {

View file

@ -29,7 +29,8 @@ struct NotchCustomizationSettingsView: View {
// The enclosing SettingsCard already provides the title,
// padding, background, and border. We just emit the rows.
VStack(alignment: .leading, spacing: 8) {
themeRow
themeSection
buddyStyleRow
fontSizeRow
hoverSpeedRow
@ -58,47 +59,95 @@ struct NotchCustomizationSettingsView: View {
}
}
// MARK: - Theme picker row
// MARK: - Theme picker grid of preview cards
private var themeRow: some View {
controlRow(icon: "paintpalette", label: L10n.notchTheme) {
Menu {
/// Header row + 2-column grid of theme cards. Each card shows a mini
/// capsule rendered in the target theme's own colors so the user can
/// judge the palette at a glance, not just "green dot says Forest".
/// Selected card glows with its own accent each theme announces
/// itself the way the Claude Design mock does.
private var themeSection: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(spacing: 10) {
Image(systemName: "paintpalette")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.5))
.frame(width: 16)
Text(L10n.notchTheme)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white.opacity(0.7))
Spacer()
Text(L10n.notchThemeName(store.customization.theme))
.font(.system(size: 11, weight: .medium))
.foregroundColor(NotchPalette.for(store.customization.theme).accent)
}
.padding(.horizontal, 2)
LazyVGrid(
columns: [
GridItem(.flexible(), spacing: 8),
GridItem(.flexible(), spacing: 8),
],
spacing: 8
) {
ForEach(NotchThemeID.allCases) { id in
Button {
ThemePreviewCard(
themeID: id,
isSelected: store.customization.theme == id
) {
store.update { $0.theme = id }
} label: {
Label {
Text(L10n.notchThemeName(id))
} icon: {
Circle().fill(NotchPalette.for(id).bg)
}
.accessibilityLabel("\(L10n.notchThemeName(id)) theme")
}
}
} label: {
HStack(spacing: 6) {
Circle()
.fill(NotchPalette.for(store.customization.theme).bg)
.overlay(
Circle()
.strokeBorder(Color.white.opacity(0.3), lineWidth: 0.5)
)
.frame(width: 12, height: 12)
.accessibilityHidden(true)
Text(L10n.notchThemeName(store.customization.theme))
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white.opacity(0.95))
Image(systemName: "chevron.up.chevron.down")
.font(.system(size: 9))
.foregroundColor(.white.opacity(0.5))
}
}
.buttonStyle(.plain)
.menuStyle(.borderlessButton)
.menuIndicator(.hidden)
.fixedSize()
.accessibilityLabel(L10n.notchTheme)
}
.padding(.bottom, 4)
}
// MARK: - Buddy style segmented picker row
/// Two-way segmented picker for which sprite appears in the notch:
/// pixel cat (always available) / Claude Code companion emoji. Emoji
/// needs `~/.claude.json` to have `companion` data or it falls back
/// to the pixel cat at render time. The pick also mirrors into the
/// legacy `usePixelCat` AppStorage so ClaudeInstancesView (which
/// hasn't been migrated) stays roughly in sync.
private var buddyStyleRow: some View {
controlRow(icon: "cat", label: L10n.notchBuddyStyle) {
HStack(spacing: 0) {
buddyStyleSegment(.pixelCat, shortLabel: L10n.notchBuddyPixelCat)
buddyStyleSegment(.emoji, shortLabel: L10n.notchBuddyEmoji)
}
.padding(2)
.background(
RoundedRectangle(cornerRadius: 6).fill(Color.white.opacity(0.06))
)
}
}
private func buddyStyleSegment(
_ style: BuddyStyle,
shortLabel: String
) -> some View {
let isSelected = store.customization.buddyStyle == style
return Button {
store.update { $0.buddyStyle = style }
// Keep legacy AppStorage in sync so unmigrated call sites
// (ClaudeInstancesView) still render something sensible.
UserDefaults.standard.set(style == .pixelCat, forKey: "usePixelCat")
} label: {
Text(shortLabel)
.font(.system(size: 11, weight: isSelected ? .bold : .medium))
.foregroundColor(isSelected ? .black : .white.opacity(0.7))
.frame(minWidth: 36)
.padding(.horizontal, 6)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 5)
.fill(isSelected ? Self.brandLime : Color.clear)
)
}
.buttonStyle(.plain)
.accessibilityLabel(shortLabel)
}
// MARK: - Font size segmented picker row
@ -313,3 +362,106 @@ struct NotchCustomizationSettingsView: View {
)
}
}
// MARK: - Theme preview card
/// One cell in the theme grid. Shows a miniature pill rendered in the
/// target theme's palette: accent dot + status dot + "" text + "×1"
/// badge, matching the real notch's idle-state layout so the swatch
/// communicates how the theme reads in situ. Selected cards glow in
/// their own accent color (each theme announces itself).
private struct ThemePreviewCard: View {
let themeID: NotchThemeID
let isSelected: Bool
let onTap: () -> Void
@State private var isHovered = false
private var palette: NotchPalette { NotchPalette.for(themeID) }
private var idleLabel: String {
// Theme-flavored labels, mirroring the state text from the Claude
// Design reference (themes.jsx per-state `label`). Feels alive
// rather than every card just saying "".
switch themeID {
case .classic: return L10n.isChinese ? "空闲" : "Idle"
case .forest: return L10n.isChinese ? "空闲" : "Idle"
case .neonTokyo: return "IDLE_"
case .sunset: return L10n.isChinese ? "静候" : "At rest"
case .retroArcade: return "IDLE"
case .highContrast: return L10n.isChinese ? "空闲" : "Idle"
case .sakura: return L10n.isChinese ? "小憩" : "Resting"
}
}
var body: some View {
Button(action: onTap) {
VStack(alignment: .leading, spacing: 10) {
// Mini pill preview rounded capsule with the theme's bg,
// accent dot, a secondary status dot, status label, and
// "×1" badge. Same layout as the real notch's left wing
// at closed-state idle.
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(palette.bg)
.overlay(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(
palette.fg.opacity(0.08),
lineWidth: 0.5
)
)
HStack(spacing: 6) {
Circle()
.fill(palette.accent)
.frame(width: 6, height: 6)
Circle()
.fill(palette.fg.opacity(0.82))
.frame(width: 8, height: 8)
Text(idleLabel)
.font(.system(
size: themeID == .retroArcade ? 9 : 10,
weight: themeID == .retroArcade ? .bold : .medium,
design: .monospaced
))
.foregroundColor(palette.fg)
.lineLimit(1)
.textCase(themeID == .retroArcade ? .uppercase : nil)
Spacer(minLength: 4)
Text("×1")
.font(.system(size: 10, design: .monospaced))
.foregroundColor(palette.secondaryFg)
}
.padding(.horizontal, 10)
}
.frame(height: 28)
Text(L10n.notchThemeName(themeID))
.font(.system(size: 11, weight: isSelected ? .semibold : .medium))
.foregroundColor(.white.opacity(isSelected ? 0.95 : 0.72))
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isSelected
? palette.accent.opacity(0.10)
: (isHovered
? Color.white.opacity(0.05)
: Color.white.opacity(0.02)))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(
isSelected ? palette.accent : Color.white.opacity(0.08),
lineWidth: isSelected ? 1.5 : 0.5
)
)
}
.buttonStyle(.plain)
.onHover { isHovered = $0 }
.accessibilityLabel("\(L10n.notchThemeName(themeID)) theme")
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}

View file

@ -882,7 +882,13 @@ struct CollapsedNotchContent: View {
return statusDotColor
}
/// Status dot color for the left wing
/// Status dot color for the left wing.
/// Semantic states (working / needsYou / error / done / thinking) stay
/// universal they carry meaning the user reads across themes. Idle
/// defers to the current theme's accent so each theme announces itself
/// at rest ( moss-green, hot pink, coral, etc.),
/// and the old `Color.white.opacity(0.3)` hardcode is gone it was
/// invisible on light-bg themes (sunset / sakura / retroArcade).
private var statusDotColor: Color {
switch mostUrgentState {
case .working: return Color(red: 0.4, green: 0.91, blue: 0.98) // cyan
@ -890,7 +896,7 @@ struct CollapsedNotchContent: View {
case .error: return Color(red: 0.94, green: 0.27, blue: 0.27) // red
case .done: return Color(red: 0.29, green: 0.87, blue: 0.5) // green
case .thinking: return Color(red: 0.7, green: 0.6, blue: 1.0) // purple
case .idle: return Color.white.opacity(0.3)
case .idle: return NotchPalette.for(notchStore.customization.theme).accent
}
}
@ -905,23 +911,29 @@ struct CollapsedNotchContent: View {
.shadow(color: effectiveStatusDotColor.opacity(0.5), radius: 3)
.opacity(pulsePhase ? 1.0 : 0.5)
// Buddy icon honors the showBuddy preference.
// Buddy icon honors the showBuddy preference and the
// user's buddy-style pick (pixelCat / emoji). Emoji mode
// falls through to pixel cat when Claude Code has no
// companion data in ~/.claude.json.
if notchStore.customization.showBuddy {
if usePixelCat {
PixelCharacterView(state: mostUrgentState)
.scaleEffect(0.28)
.frame(width: 16, height: 16)
.matchedGeometryEffect(id: "crab", in: activityNamespace, isSource: true)
} else if let buddy = buddyReader.buddy {
EmojiPixelView(emoji: buddy.species.emoji, style: .wave)
.scaleEffect(0.30)
.frame(width: 16, height: 16)
.matchedGeometryEffect(id: "crab", in: activityNamespace, isSource: true)
} else {
switch notchStore.customization.buddyStyle {
case .pixelCat:
PixelCharacterView(state: mostUrgentState)
.scaleEffect(0.28)
.frame(width: 16, height: 16)
.matchedGeometryEffect(id: "crab", in: activityNamespace, isSource: true)
case .emoji:
if let buddy = buddyReader.buddy {
EmojiPixelView(emoji: buddy.species.emoji, style: .wave)
.scaleEffect(0.30)
.frame(width: 16, height: 16)
.matchedGeometryEffect(id: "crab", in: activityNamespace, isSource: true)
} else {
PixelCharacterView(state: mostUrgentState)
.scaleEffect(0.28)
.frame(width: 16, height: 16)
.matchedGeometryEffect(id: "crab", in: activityNamespace, isSource: true)
}
}
}
@ -1114,7 +1126,9 @@ struct CollapsedNotchContent: View {
}
}
/// Badge color based on most urgent state
/// Badge color for the "×N" session count. Idle defers to theme accent
/// so the "×1" pill also reflects the active theme at rest, instead of
/// a hardcoded 30%-white that vanishes on light-bg themes.
private var badgeColor: Color {
switch mostUrgentState {
case .needsYou: return TerminalColors.amber
@ -1122,7 +1136,7 @@ struct CollapsedNotchContent: View {
case .working: return TerminalColors.green
case .thinking: return Color(red: 0.65, green: 0.55, blue: 0.98)
case .done: return TerminalColors.blue
case .idle: return Color.white.opacity(0.3)
case .idle: return NotchPalette.for(notchStore.customization.theme).accent
}
}

File diff suppressed because it is too large Load diff

View file

@ -40,13 +40,13 @@ final class NotchCustomizationStoreTests: XCTestCase {
func test_init_withExistingV1Key_loadsIt() throws {
var persisted = NotchCustomization.default
persisted.theme = .cyber
persisted.theme = .neonTokyo
persisted.defaultGeometry.maxWidth = 520
let data = try JSONEncoder().encode(persisted)
UserDefaults.standard.set(data, forKey: v1Key)
let store = NotchCustomizationStore()
XCTAssertEqual(store.customization.theme, .cyber)
XCTAssertEqual(store.customization.theme, .neonTokyo)
XCTAssertEqual(store.customization.defaultGeometry.maxWidth, 520)
}
@ -78,13 +78,13 @@ final class NotchCustomizationStoreTests: XCTestCase {
func test_update_mutatesAndPersists() throws {
let store = NotchCustomizationStore()
store.update { $0.theme = .paper }
store.update { $0.theme = .forest }
XCTAssertEqual(store.customization.theme, .paper)
XCTAssertEqual(store.customization.theme, .forest)
let data = try XCTUnwrap(UserDefaults.standard.data(forKey: v1Key))
let decoded = try JSONDecoder().decode(NotchCustomization.self, from: data)
XCTAssertEqual(decoded.theme, .paper)
XCTAssertEqual(decoded.theme, .forest)
}
func test_updateGeometry_mutatesAndPersists() throws {
@ -131,10 +131,10 @@ final class NotchCustomizationStoreTests: XCTestCase {
func test_editLifecycle_persistsCommittedChangesAcrossSimulatedReload() throws {
let store1 = NotchCustomizationStore()
store1.enterEditMode()
store1.update { $0.theme = .mint }
store1.update { $0.theme = .sakura }
store1.commitEdit()
let store2 = NotchCustomizationStore()
XCTAssertEqual(store2.customization.theme, .mint)
XCTAssertEqual(store2.customization.theme, .sakura)
}
}

View file

@ -36,7 +36,7 @@ final class NotchCustomizationTests: XCTestCase {
func test_codable_roundtripPreservesAllFields() throws {
var original = NotchCustomization.default
original.theme = .neonLime
original.theme = .neonTokyo
original.fontScale = .large
original.showBuddy = false
original.showUsageBar = false
@ -54,12 +54,12 @@ final class NotchCustomizationTests: XCTestCase {
// Older persisted blobs (or ones produced by a hypothetical
// pre-release) may be missing some fields. Decoding should
// succeed and fill missing fields with struct defaults.
let partial = #"{"theme":"cyber"}"#
let partial = #"{"theme":"forest"}"#
let decoded = try JSONDecoder().decode(
NotchCustomization.self,
from: Data(partial.utf8)
)
XCTAssertEqual(decoded.theme, .cyber)
XCTAssertEqual(decoded.theme, .forest)
XCTAssertEqual(decoded.fontScale, .default)
XCTAssertTrue(decoded.showBuddy)
XCTAssertTrue(decoded.showUsageBar)
@ -68,6 +68,19 @@ final class NotchCustomizationTests: XCTestCase {
XCTAssertEqual(decoded.hardwareNotchMode, .auto)
}
/// v1 v2 theme reset (2026-04-20): dropped themes like "paper",
/// "cyber", "mint", "rosegold" should fall back to `.classic` rather
/// than throwing on decode. Regression guard for the graceful-decode
/// behavior added alongside the theme reset.
func test_codable_unknownThemeFallsBackToClassic() throws {
let legacy = #"{"theme":"rosegold"}"#
let decoded = try JSONDecoder().decode(
NotchCustomization.self,
from: Data(legacy.utf8)
)
XCTAssertEqual(decoded.theme, .classic)
}
// MARK: - FontScale
func test_fontScale_multiplierMapping() {

View file

@ -44,11 +44,12 @@ final class NotchThemeTests: XCTestCase {
}
func test_themeRawStringsMatchCaseNames() {
XCTAssertEqual(NotchThemeID.classic.rawValue, "classic")
XCTAssertEqual(NotchThemeID.paper.rawValue, "paper")
XCTAssertEqual(NotchThemeID.neonLime.rawValue, "neonLime")
XCTAssertEqual(NotchThemeID.cyber.rawValue, "cyber")
XCTAssertEqual(NotchThemeID.mint.rawValue, "mint")
XCTAssertEqual(NotchThemeID.sunset.rawValue, "sunset")
XCTAssertEqual(NotchThemeID.classic.rawValue, "classic")
XCTAssertEqual(NotchThemeID.forest.rawValue, "forest")
XCTAssertEqual(NotchThemeID.neonTokyo.rawValue, "neonTokyo")
XCTAssertEqual(NotchThemeID.sunset.rawValue, "sunset")
XCTAssertEqual(NotchThemeID.retroArcade.rawValue, "retroArcade")
XCTAssertEqual(NotchThemeID.highContrast.rawValue, "highContrast")
XCTAssertEqual(NotchThemeID.sakura.rawValue, "sakura")
}
}