diff --git a/ClaudeIsland/Core/Localization.swift b/ClaudeIsland/Core/Localization.swift index 2c24255f..01ca9fde 100644 --- a/ClaudeIsland/Core/Localization.swift +++ b/ClaudeIsland/Core/Localization.swift @@ -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 } } } diff --git a/ClaudeIsland/Models/NotchCustomization.swift b/ClaudeIsland/Models/NotchCustomization.swift index e511c569..7043ea71 100644 --- a/ClaudeIsland/Models/NotchCustomization.swift +++ b/ClaudeIsland/Models/NotchCustomization.swift @@ -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 } } diff --git a/ClaudeIsland/Models/NotchTheme.swift b/ClaudeIsland/Models/NotchTheme.swift index 0a8d0ebd..f9366387 100644 --- a/ClaudeIsland/Models/NotchTheme.swift +++ b/ClaudeIsland/Models/NotchTheme.swift @@ -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" } } } diff --git a/ClaudeIsland/Services/Plugin/BuiltInPlugins.swift b/ClaudeIsland/Services/Plugin/BuiltInPlugins.swift index 09bb2d18..f5ef155d 100644 --- a/ClaudeIsland/Services/Plugin/BuiltInPlugins.swift +++ b/ClaudeIsland/Services/Plugin/BuiltInPlugins.swift @@ -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 diff --git a/ClaudeIsland/Services/Plugin/NativePluginManager.swift b/ClaudeIsland/Services/Plugin/NativePluginManager.swift index f51ac625..6a09940a 100644 --- a/ClaudeIsland/Services/Plugin/NativePluginManager.swift +++ b/ClaudeIsland/Services/Plugin/NativePluginManager.swift @@ -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 } diff --git a/ClaudeIsland/UI/Views/NativePluginStoreView.swift b/ClaudeIsland/UI/Views/NativePluginStoreView.swift index 70953318..e7e046be 100644 --- a/ClaudeIsland/UI/Views/NativePluginStoreView.swift +++ b/ClaudeIsland/UI/Views/NativePluginStoreView.swift @@ -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) { diff --git a/ClaudeIsland/UI/Views/NotchCustomizationSettingsView.swift b/ClaudeIsland/UI/Views/NotchCustomizationSettingsView.swift index 94779c1d..49be2221 100644 --- a/ClaudeIsland/UI/Views/NotchCustomizationSettingsView.swift +++ b/ClaudeIsland/UI/Views/NotchCustomizationSettingsView.swift @@ -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 : []) + } +} + diff --git a/ClaudeIsland/UI/Views/NotchView.swift b/ClaudeIsland/UI/Views/NotchView.swift index c8e2aad6..a3af9e8c 100644 --- a/ClaudeIsland/UI/Views/NotchView.swift +++ b/ClaudeIsland/UI/Views/NotchView.swift @@ -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 } } diff --git a/ClaudeIsland/UI/Views/SystemSettingsView.swift b/ClaudeIsland/UI/Views/SystemSettingsView.swift index d7883eec..19b188aa 100644 --- a/ClaudeIsland/UI/Views/SystemSettingsView.swift +++ b/ClaudeIsland/UI/Views/SystemSettingsView.swift @@ -82,10 +82,14 @@ final class SystemSettingsWindow { return } - let contentView = SystemSettingsContentView(initialTab: initialTab) { self.close() } + let contentView = SystemSettingsContentView( + initialTab: initialTab, + onClose: { self.close() }, + onHide: { self.hide() } + ) let hostingView = NSHostingView(rootView: contentView) let w = KeyableSettingsWindow( - contentRect: NSRect(x: 0, y: 0, width: 720, height: 560), + contentRect: NSRect(x: 0, y: 0, width: 960, height: 720), styleMask: [.borderless], backing: .buffered, defer: false @@ -101,7 +105,7 @@ final class SystemSettingsWindow { if let screen = NSScreen.main { let f = screen.frame - w.setFrameOrigin(NSPoint(x: f.midX - 360, y: f.midY - 280)) + w.setFrameOrigin(NSPoint(x: f.midX - 480, y: f.midY - 360)) } w.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow))) @@ -115,6 +119,13 @@ final class SystemSettingsWindow { window?.close() window = nil } + + /// Hide the window without destroying it — next `show()` re-foregrounds the + /// same instance (state preserved). Used by the titlebar minimize button; + /// borderless windows can't `miniaturize` to the Dock, so we `orderOut`. + func hide() { + window?.orderOut(nil) + } } // MARK: - Tab enum @@ -162,28 +173,61 @@ enum SettingsTab: String, CaseIterable, Identifiable { case .about: return L10n.tabAbout } } + + /// English subtitle shown next to the Chinese H1 on each detail pane — + /// mirrors the reference mock's "通用 General preferences" pattern. + /// When the UI is already English, we skip it to avoid duplicating the title. + var englishSubtitle: String { + guard L10n.isChinese else { return "" } + switch self { + case .general: return "General preferences" + case .appearance: return "Appearance" + case .notifications: return "Notifications" + case .behavior: return "Behavior" + case .plugins: return "Plugins & Extensions" + case .codelight: return "CodeLight" + case .cmuxConnection: return "cmux Connection" + case .logs: return "Logs" + case .advanced: return "Advanced" + case .about: return "About" + } + } } // MARK: - Shared theming constants -/// Two-surface theme: the sidebar is a bold lime strip, the detail area is -/// a dark panel so the content doesn't feel retina-burning. This also -/// matches the existing dark-themed embedded rows (ScreenPickerRow, etc.) -/// without forcing a colorScheme override on them. +/// Graphite two-surface theme: sidebar is a warm charcoal (`#201f27`), +/// detail area is a slightly darker graphite (`#1c1c1e`). Lime survives +/// only as an accent on toggles, active sidebar icons, and focus rings. +/// Palette is lifted from the Anthropic-style reference design — see +/// `~/Desktop/1_files/UI.jsx` and the System Settings HTML mock. private enum Theme { - // Brand lime — ONLY used on the sidebar surface. - static let sidebarFill = Color(red: 0xCA/255, green: 0xFF/255, blue: 0x00/255) - static let sidebarText = Color.black - static let sidebarSelected = Color.black.opacity(0.85) - static let sidebarSelectedText = Color(red: 0xCA/255, green: 0xFF/255, blue: 0x00/255) - static let sidebarBorder = Color.black.opacity(0.12) + // Sidebar — warm charcoal, NOT lime anymore. + static let sidebarFill = Color(red: 0x20/255, green: 0x1F/255, blue: 0x27/255) + static let sidebarText = Color.white + static let sidebarActiveFill = Color.white.opacity(0.08) + static let sidebarHoverFill = Color.white.opacity(0.04) + static let sidebarBorder = Color.white.opacity(0.06) - // Dark panel — used for the detail area, cards, toggles, text. - static let detailFill = Color(red: 0.10, green: 0.10, blue: 0.11) + // Detail panel — graphite, close to real macOS System Settings. + static let detailFill = Color(red: 0x1C/255, green: 0x1C/255, blue: 0x1E/255) static let detailText = Color.white - static let cardFill = Color.white.opacity(0.04) + + // Cards and rows. + static let cardFill = Color.white.opacity(0.03) static let cardBorder = Color.white.opacity(0.08) - static let subtle = Color.white.opacity(0.5) + static let rowDivider = Color.white.opacity(0.06) + static let subtle = Color.white.opacity(0.42) + static let subtleStrong = Color.white.opacity(0.72) + + // Accent (neon lime) — used sparingly: toggles, active icons, focus. + static let accent = Color(red: 0xC6/255, green: 0xFF/255, blue: 0x3A/255) + + // Real macOS traffic-light colors. + static let tlRed = Color(red: 1.00, green: 0.373, blue: 0.341) + static let tlYellow = Color(red: 0.996, green: 0.737, blue: 0.180) + static let tlGreen = Color(red: 0.157, green: 0.784, blue: 0.251) + static let tlStroke = Color.black.opacity(0.25) } // MARK: - Content root @@ -191,11 +235,18 @@ private enum Theme { private struct SystemSettingsContentView: View { let initialTab: SettingsTab let onClose: () -> Void + let onHide: () -> Void @State private var tab: SettingsTab + @State private var isHoveringTitleBar = false - init(initialTab: SettingsTab = .general, onClose: @escaping () -> Void) { + init( + initialTab: SettingsTab = .general, + onClose: @escaping () -> Void, + onHide: @escaping () -> Void + ) { self.initialTab = initialTab self.onClose = onClose + self.onHide = onHide self._tab = State(initialValue: initialTab) } @@ -204,89 +255,127 @@ private struct SystemSettingsContentView: View { // cut the sidebar's opaque lime fill and the detail's dark fill, // then the overlay border is stroked on the clipped edge on top. // Putting shadow OUTSIDE the clip so it isn't cut off. - HStack(spacing: 0) { - sidebar - detail + VStack(spacing: 0) { + titleBar + HStack(spacing: 0) { + sidebar + detail + } } - .frame(width: 720, height: 560) + .frame(width: 960, height: 720) .clipShape(RoundedRectangle(cornerRadius: 16)) .overlay( RoundedRectangle(cornerRadius: 16) .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5) ) .shadow(color: .black.opacity(0.5), radius: 30, y: 12) + .onHover { isHoveringTitleBar = $0 } + } + + // MARK: Title bar + + /// Real macOS-style chrome: red/yellow/green dots on the left, centered + /// title. Borderless windows have no OS chrome, so we synthesize it. + private var titleBar: some View { + ZStack { + HStack(spacing: 8) { + trafficLight(fill: Theme.tlRed, glyph: "xmark", action: onClose) + trafficLight(fill: Theme.tlYellow, glyph: "minus", action: onHide) + // Green is decorative (no fullscreen for a utility window). + Circle() + .fill(Theme.tlGreen) + .frame(width: 12, height: 12) + .overlay(Circle().strokeBorder(Theme.tlStroke, lineWidth: 0.5)) + Spacer() + } + .padding(.horizontal, 14) + + Text(L10n.systemSettings) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.white.opacity(0.85)) + } + .frame(height: 38) + .background(Theme.sidebarFill) + .overlay( + Rectangle() + .fill(Color.black.opacity(0.4)) + .frame(height: 0.5), + alignment: .bottom + ) + } + + @ViewBuilder + private func trafficLight(fill: Color, glyph: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Circle() + .fill(fill) + .frame(width: 12, height: 12) + .overlay( + Image(systemName: glyph) + .font(.system(size: 7, weight: .bold)) + .foregroundColor(.black.opacity(isHoveringTitleBar ? 0.6 : 0)) + ) + .overlay(Circle().strokeBorder(Theme.tlStroke, lineWidth: 0.5)) + } + .buttonStyle(.plain) } // MARK: Sidebar private var sidebar: some View { VStack(alignment: .leading, spacing: 0) { - // Title - HStack(spacing: 6) { - Image(systemName: "gearshape.fill") - .font(.system(size: 13)) - .foregroundColor(Theme.sidebarText.opacity(0.75)) - Text(L10n.systemSettings) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(Theme.sidebarText.opacity(0.9)) - } - .padding(.horizontal, 14) - .padding(.top, 18) - .padding(.bottom, 14) - - // Tab list + Color.clear.frame(height: 10) ForEach(SettingsTab.allCases) { t in tabRow(t) } Spacer() - // Close button at bottom + Rectangle() + .fill(Color.white.opacity(0.06)) + .frame(height: 0.5) + .padding(.horizontal, 10) + Button { - onClose() + NSApplication.shared.terminate(nil) } label: { - HStack(spacing: 6) { - Image(systemName: "xmark.circle.fill") + HStack(spacing: 8) { + Image(systemName: "power") .font(.system(size: 12)) - Text(L10n.back) - .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.55)) + .frame(width: 18) + Text(L10n.isChinese ? "退出" : "Quit") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + Spacer() } - .foregroundColor(Theme.sidebarText.opacity(0.55)) - .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 14) - .padding(.vertical, 10) + .padding(.vertical, 11) + .contentShape(Rectangle()) } .buttonStyle(.plain) } - .frame(width: 180) + .frame(width: 196) .background(Theme.sidebarFill) + .overlay( + Rectangle() + .fill(Color.black.opacity(0.3)) + .frame(width: 0.5), + alignment: .trailing + ) } @ViewBuilder private func tabRow(_ t: SettingsTab) -> some View { let isSelected = tab == t - Button { - withAnimation(.easeOut(duration: 0.15)) { tab = t } - } label: { - HStack(spacing: 10) { - Image(systemName: t.icon) - .font(.system(size: 12)) - .frame(width: 18) - Text(t.label) - .font(.system(size: 12, weight: isSelected ? .semibold : .medium)) - Spacer(minLength: 0) + SidebarPillRow( + icon: t.icon, + label: t.label, + isSelected: isSelected, + action: { + withAnimation(.easeOut(duration: 0.15)) { tab = t } } - .foregroundColor(isSelected ? Theme.sidebarSelectedText : Theme.sidebarText.opacity(0.78)) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background( - RoundedRectangle(cornerRadius: 8) - .fill(isSelected ? Theme.sidebarSelected : Color.clear) - ) - .padding(.horizontal, 8) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + ) } // MARK: Detail @@ -295,10 +384,18 @@ private struct SystemSettingsContentView: View { private var detail: some View { ScrollView(.vertical, showsIndicators: false) { VStack(alignment: .leading, spacing: 16) { - Text(tab.label) - .font(.system(size: 20, weight: .bold)) - .foregroundColor(Theme.detailText.opacity(0.95)) - .padding(.top, 18) + // Large H1 + English subtitle, mirroring the reference mock. + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(tab.label) + .font(.system(size: 26, weight: .semibold)) + .foregroundColor(.white) + .tracking(-0.4) + Text(tab.englishSubtitle) + .font(.system(size: 12)) + .foregroundColor(Theme.subtle) + } + .padding(.top, 22) + .padding(.bottom, 4) switch tab { case .general: GeneralTab() @@ -313,8 +410,8 @@ private struct SystemSettingsContentView: View { case .about: AboutTab() } } - .padding(.horizontal, 22) - .padding(.bottom, 22) + .padding(.horizontal, 26) + .padding(.bottom, 26) } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Theme.detailFill) @@ -323,8 +420,46 @@ private struct SystemSettingsContentView: View { // MARK: - Reusable tab-level primitives -/// A bordered card container used by each tab to group related controls. -/// Dark theme: translucent white fill over the detail panel, thin border. +/// Sidebar pill: hover = subtle fill, active = slightly stronger fill + lime +/// icon. Hoisted out of the content view so we can hold per-row hover state. +private struct SidebarPillRow: View { + let icon: String + let label: String + let isSelected: Bool + let action: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: icon) + .font(.system(size: 12)) + .foregroundColor(isSelected ? Theme.accent : .white.opacity(0.55)) + .frame(width: 18) + Text(label) + .font(.system(size: 13, weight: isSelected ? .semibold : .regular)) + .foregroundColor(isSelected ? .white : Theme.subtleStrong) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(isSelected + ? Theme.sidebarActiveFill + : (isHovered ? Theme.sidebarHoverFill : Color.clear)) + ) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovered = $0 } + } +} + +/// Card container. Reference design uses `rgba(255,255,255,0.03)` fill + +/// `rgba(255,255,255,0.08)` border at radius 12. The optional uppercase +/// "section label" now renders *above* the card, not inside it. private struct SettingsCard: View { let title: String? @ViewBuilder let content: Content @@ -335,24 +470,26 @@ private struct SettingsCard: View { } var body: some View { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 8) { if let title { Text(title) - .font(.system(size: 10, weight: .semibold)) + .font(.system(size: 11, weight: .semibold)) .textCase(.uppercase) .tracking(0.6) .foregroundColor(Theme.subtle) + .padding(.horizontal, 4) + .padding(.top, 8) } - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { content } - .padding(12) + .padding(14) .frame(maxWidth: .infinity, alignment: .leading) .background( - RoundedRectangle(cornerRadius: 10) + RoundedRectangle(cornerRadius: 12) .fill(Theme.cardFill) .overlay( - RoundedRectangle(cornerRadius: 10) + RoundedRectangle(cornerRadius: 12) .strokeBorder(Theme.cardBorder, lineWidth: 0.5) ) ) @@ -360,7 +497,52 @@ private struct SettingsCard: View { } } -/// Dark-themed toggle cell — lime dot when on, matching the sidebar accent. +/// iOS-style pill toggle matching the reference mock: neon-lime gradient +/// when on, inset charcoal when off, with a radial-highlight knob that +/// animates between ends. +private struct IOSToggle: View { + let isOn: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + ZStack(alignment: isOn ? .trailing : .leading) { + Capsule() + .fill(isOn ? AnyShapeStyle(LinearGradient( + colors: [Theme.accent, Theme.accent.opacity(0.87)], + startPoint: .top, endPoint: .bottom + )) : AnyShapeStyle(Color.white.opacity(0.10))) + .overlay( + Capsule() + .strokeBorder( + isOn ? Color.black.opacity(0.25) : Color.white.opacity(0.08), + lineWidth: 0.5 + ) + ) + .shadow( + color: isOn ? Theme.accent.opacity(0.3) : .clear, + radius: 6, y: 2 + ) + + Circle() + .fill(RadialGradient( + colors: [Color.white, Color(white: 0.95), Color(white: 0.88)], + center: UnitPoint(x: 0.4, y: 0.35), + startRadius: 0, endRadius: 14 + )) + .frame(width: 19, height: 19) + .shadow(color: .black.opacity(0.35), radius: 1.5, y: 1) + .padding(2) + } + .frame(width: 38, height: 23) + .animation(.spring(response: 0.26, dampingFraction: 0.7), value: isOn) + } + .buttonStyle(.plain) + } +} + +/// Toggle cell — icon tile + label + iOS slider. Adopts the reference +/// "setting row" pattern (icon square, main label, optional sublabel). private struct TabToggle: View { let icon: String let label: String @@ -368,32 +550,194 @@ private struct TabToggle: View { let action: () -> Void var body: some View { - Button(action: action) { - HStack(spacing: 10) { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 7) + .fill(Color.white.opacity(0.05)) + .overlay( + RoundedRectangle(cornerRadius: 7) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5) + ) Image(systemName: icon) .font(.system(size: 12)) - .foregroundColor(.white.opacity(isOn ? 0.9 : 0.5)) - .frame(width: 16) - Text(label) - .font(.system(size: 12, weight: isOn ? .semibold : .medium)) - .foregroundColor(.white.opacity(isOn ? 0.95 : 0.7)) - Spacer(minLength: 0) - Circle() - .fill(isOn ? Theme.sidebarFill : Color.white.opacity(0.18)) - .frame(width: 7, height: 7) + .foregroundColor(isOn ? Theme.accent : .white.opacity(0.72)) } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 7) - .fill(isOn ? Theme.sidebarFill.opacity(0.1) : Color.white.opacity(0.03)) - ) - .overlay( - RoundedRectangle(cornerRadius: 7) - .strokeBorder(isOn ? Theme.sidebarFill.opacity(0.25) : Color.white.opacity(0.08), lineWidth: 0.5) - ) + .frame(width: 28, height: 28) + + Text(label) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white.opacity(0.92)) + + Spacer(minLength: 0) + + IOSToggle(isOn: isOn, action: action) + } + .contentShape(Rectangle()) + } +} + +// MARK: - Reference-style list-of-rows primitives + +/// Section label above a card: uppercase, tracked, muted. +/// Usage: `SectionLabel(L10n.someSection)` then `SettingsListCard { ... }`. +private struct SectionLabel: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Text(text) + .font(.system(size: 11, weight: .semibold)) + .textCase(.uppercase) + .tracking(0.6) + .foregroundColor(Theme.subtle) + .padding(.horizontal, 4) + .padding(.top, 6) + } +} + +/// Card sized for a vertical list of SettingRow. Uses tight vertical padding +/// so rows' own 12pt vertical padding drives the row height — matches the +/// reference mock's `padding: '4px 16px'` row card. +private struct SettingsListCard: View { + @ViewBuilder let content: Content + + var body: some View { + VStack(spacing: 0) { + content + } + .padding(.horizontal, 16) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Theme.cardFill) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Theme.cardBorder, lineWidth: 0.5) + ) + ) + } +} + +/// A single list row: optional icon tile, label, optional sublabel, control. +/// `isLast` suppresses the bottom divider so the final row sits flush with the +/// card's bottom padding. +private struct SettingRow: View { + let icon: String? + let label: String + let sublabel: String? + let isLast: Bool + @ViewBuilder let control: () -> Control + + init( + icon: String? = nil, + label: String, + sublabel: String? = nil, + isLast: Bool = false, + @ViewBuilder control: @escaping () -> Control + ) { + self.icon = icon + self.label = label + self.sublabel = sublabel + self.isLast = isLast + self.control = control + } + + var body: some View { + HStack(spacing: 12) { + if let icon { + ZStack { + RoundedRectangle(cornerRadius: 7) + .fill(Color.white.opacity(0.05)) + .overlay( + RoundedRectangle(cornerRadius: 7) + .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5) + ) + Image(systemName: icon) + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.75)) + } + .frame(width: 28, height: 28) + } + + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white.opacity(0.92)) + if let sublabel, !sublabel.isEmpty { + Text(sublabel) + .font(.system(size: 11)) + .foregroundColor(Theme.subtle) + } + } + + Spacer(minLength: 8) + + control() + } + .padding(.vertical, 12) + .overlay( + Rectangle() + .fill(Theme.rowDivider) + .frame(height: 0.5) + .opacity(isLast ? 0 : 1), + alignment: .bottom + ) + } +} + +/// Colored dot + title + body, used in the proxy explanation card. +/// `variant` controls dot color + glyph: +/// - .pos → accent-filled, "✓" +/// - .neg → muted outline, "✕" +/// - .hint → muted outline, "i" +private struct InfoRow: View { + enum Variant { case pos, neg, hint } + let variant: Variant + let title: String + let message: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + dot + (Text(title + ":") + .foregroundColor(.white.opacity(0.9)) + .font(.system(size: 12, weight: .medium)) + + Text(message) + .foregroundColor(.white.opacity(0.6)) + .font(.system(size: 12))) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 2) + } + + @ViewBuilder + private var dot: some View { + let isPos = variant == .pos + ZStack { + Circle() + .fill(isPos ? Theme.accent : Color.white.opacity(0.06)) + .overlay( + Circle().strokeBorder( + isPos ? Color.clear : Color.white.opacity(0.12), + lineWidth: 0.5 + ) + ) + Text(glyph) + .font(.system(size: 9, weight: .bold)) + .foregroundColor(isPos ? Color(red: 0.04, green: 0.04, blue: 0.05) : .white.opacity(0.7)) + } + .frame(width: 16, height: 16) + .padding(.top, 1) + } + + private var glyph: String { + switch variant { + case .pos: return "✓" + case .neg: return "✕" + case .hint: return "i" } - .buttonStyle(.plain) } } @@ -405,10 +749,18 @@ private struct GeneralTab: View { @ObservedObject private var codexGate = CodexFeatureGate.shared var body: some View { - VStack(alignment: .leading, spacing: 14) { - SettingsCard { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { - TabToggle(icon: "power", label: L10n.launchAtLogin, isOn: launchAtLogin) { + VStack(alignment: .leading, spacing: 18) { + // Quick toggles — vertical list with dividers, sublabels for each. + SectionLabel(L10n.isChinese ? "快速开关" : "Quick Toggles") + SettingsListCard { + SettingRow( + icon: "power", + label: L10n.launchAtLogin, + sublabel: L10n.isChinese + ? "系统登录时自动运行 MioIsland" + : "Run MioIsland automatically at login" + ) { + IOSToggle(isOn: launchAtLogin) { do { if launchAtLogin { try SMAppService.mainApp.unregister() @@ -419,7 +771,15 @@ private struct GeneralTab: View { } } catch {} } - TabToggle(icon: "arrow.triangle.2.circlepath", label: L10n.hooks, isOn: hooksInstalled) { + } + SettingRow( + icon: "arrow.triangle.2.circlepath", + label: L10n.hooks, + sublabel: L10n.isChinese + ? "拦截与注入 Claude CLI 生命周期" + : "Intercept and instrument the Claude CLI lifecycle" + ) { + IOSToggle(isOn: hooksInstalled) { if hooksInstalled { HookInstaller.uninstall() hooksInstalled = false @@ -428,54 +788,214 @@ private struct GeneralTab: View { hooksInstalled = true } } - TabToggle(icon: "terminal.fill", label: L10n.codexSupport, isOn: codexGate.isEnabled) { + } + SettingRow( + icon: "chevron.left.forwardslash.chevron.right", + label: L10n.codexSupport, + sublabel: L10n.isChinese + ? "启用 Codex CLI 辅助与代码建议" + : "Enable Codex CLI assistance and code suggestions", + isLast: true + ) { + IOSToggle(isOn: codexGate.isEnabled) { codexGate.isEnabled.toggle() } } } - SettingsCard(title: L10n.anthropicApiProxy) { - AnthropicProxyRow() + // Proxy + SectionLabel(L10n.anthropicApiProxy) + AnthropicProxyRow() + + // Language + SectionLabel(L10n.language) + SettingsListCard { + SettingsLanguageRow(isLast: true) } - SettingsCard(title: L10n.language) { - LanguageRow() - } - - SettingsCard(title: L10n.accessibility) { - AccessibilityRow(isEnabled: AXIsProcessTrusted()) + // Accessibility + SectionLabel(L10n.accessibility) + SettingsListCard { + SettingsAccessibilityRow(isLast: true) } } } } -/// Text field for configuring an HTTP(S) proxy for Anthropic API traffic. -/// See the explanatory Text below for exact scope. +/// Proxy input + three "作用于 / 不作用于 / 留空即直连" info rows. +/// Replaces the old single-paragraph description with the structured +/// ✓ / ✕ / i rows from the reference mock. private struct AnthropicProxyRow: View { @AppStorage("anthropicProxyURL") private var proxyURL: String = "" var body: some View { - VStack(alignment: .leading, spacing: 6) { - TextField("", text: $proxyURL, prompt: Text(L10n.anthropicApiProxyPlaceholder).foregroundColor(.white.opacity(0.3))) - .textFieldStyle(.plain) - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(.white.opacity(0.95)) + VStack(alignment: .leading, spacing: 14) { + // SwiftUI's TextField.prompt repeatedly ignores `foregroundColor` + // on macOS and falls back to its own secondary-label gray, which + // reads almost-black on our dark input fill. Roll our own: a + // manually positioned Text, only visible when empty, in a solid + // light gray we control. + ZStack(alignment: .leading) { + if proxyURL.isEmpty { + Text(L10n.anthropicApiProxyPlaceholder) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(Color(red: 0.62, green: 0.62, blue: 0.65)) + .padding(.horizontal, 12) + .allowsHitTesting(false) + } + TextField("", text: $proxyURL) + .textFieldStyle(.plain) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.white.opacity(0.95)) + .padding(.horizontal, 12) + .padding(.vertical, 10) + } + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.black.opacity(0.35)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) + ) + + VStack(alignment: .leading, spacing: 9) { + InfoRow( + variant: .pos, + title: L10n.isChinese ? "作用于" : "Applies to", + message: L10n.isChinese + ? "刘海额度条 (api.anthropic.com) 与 MioIsland 启动的所有子进程,包括 Stats 插件的 claude CLI。启动时设置一次 HTTPS_PROXY / HTTP_PROXY / ALL_PROXY,子进程自动继承。" + : "Notch usage bar (api.anthropic.com) and every subprocess spawned by MioIsland, including the Stats plugin's claude CLI. HTTPS_PROXY / HTTP_PROXY / ALL_PROXY are set once at launch and inherited." + ) + InfoRow( + variant: .neg, + title: L10n.isChinese ? "不作用于" : "Does not apply to", + message: L10n.isChinese + ? "CodeLight 同步(始终直连)、第三方插件的 URLSession 调用(走系统代理)。" + : "CodeLight sync (always direct) and third-party plugin URLSession calls (use system proxy)." + ) + InfoRow( + variant: .hint, + title: L10n.isChinese ? "留空即直连" : "Leave empty to disable", + message: L10n.isChinese + ? "无需配置代理时清空此字段即可。" + : "Clear this field when you don't need a proxy." + ) + } + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(Theme.cardFill) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder(Theme.cardBorder, lineWidth: 0.5) + ) + ) + } +} + +/// Settings-tab version of the language picker. The notch-menu version +/// (LanguageRow in NotchMenuView.swift) expands inline; here we use a +/// right-aligned Menu so it matches the reference's compact dropdown. +private struct SettingsLanguageRow: View { + let isLast: Bool + @State private var current = L10n.appLanguage + + private let options: [(id: String, label: String)] = [ + ("auto", "Auto / 自动"), + ("zh", "简体中文"), + ("en", "English"), + ] + + private var currentLabel: String { + options.first(where: { $0.id == current })?.label ?? "Auto" + } + + var body: some View { + SettingRow( + icon: "globe", + label: L10n.language, + sublabel: L10n.isChinese + ? "更改后重启应用生效" + : "Restart the app for changes to take effect", + isLast: isLast + ) { + Menu { + ForEach(options, id: \.id) { option in + Button(option.label) { + L10n.appLanguage = option.id + current = option.id + } + } + } label: { + HStack(spacing: 5) { + Text(currentLabel) + .font(.system(size: 12)) + .foregroundColor(.white.opacity(0.85)) + Image(systemName: "chevron.down") + .font(.system(size: 9, weight: .semibold)) + .foregroundColor(.white.opacity(0.55)) + } .padding(.horizontal, 10) - .padding(.vertical, 8) + .padding(.vertical, 5) .background( RoundedRectangle(cornerRadius: 7) - .fill(Color.white.opacity(0.05)) + .fill(Color.white.opacity(0.06)) ) .overlay( RoundedRectangle(cornerRadius: 7) - .strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5) + .strokeBorder(Color.white.opacity(0.10), lineWidth: 0.5) ) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + } + } +} - Text(L10n.anthropicApiProxyDescription) - .font(.system(size: 10, weight: .medium)) - .foregroundColor(Color(white: 0.75)) - .lineSpacing(4) - .fixedSize(horizontal: false, vertical: true) +/// Settings-tab accessibility row: icon + label + sublabel + status pill +/// (green dot + "已启用" when granted, "启用" button when not). +private struct SettingsAccessibilityRow: View { + let isLast: Bool + @State private var isGranted = AXIsProcessTrusted() + + var body: some View { + SettingRow( + icon: "hand.raised.fill", + label: L10n.accessibility, + sublabel: L10n.isChinese + ? "键盘快捷键与窗口控制需要此权限" + : "Required for keyboard shortcuts and window control", + isLast: isLast + ) { + if isGranted { + HStack(spacing: 6) { + Circle().fill(Theme.accent).frame(width: 6, height: 6) + Text(L10n.enabled) + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.55)) + } + } else { + Button { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") { + NSWorkspace.shared.open(url) + } + } label: { + Text(L10n.enable) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.black) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(RoundedRectangle(cornerRadius: 6).fill(Theme.accent)) + } + .buttonStyle(.plain) + } + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + isGranted = AXIsProcessTrusted() } } } @@ -485,7 +1005,6 @@ private struct AnthropicProxyRow: View { private struct AppearanceTab: View { @ObservedObject private var screenSelector = ScreenSelector.shared @AppStorage("showGroupedSessions") private var showGrouped: Bool = false - @AppStorage("usePixelCat") private var usePixelCat: Bool = false var body: some View { VStack(alignment: .leading, spacing: 14) { @@ -493,15 +1012,17 @@ private struct AppearanceTab: View { ScreenPickerRow(screenSelector: screenSelector) } + // Session-grouping toggle — sits alone now that the old + // "Pixel Cat Mode" lives inside the Notch section's new + // three-way Buddy Style picker. SettingsCard { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { - TabToggle(icon: "cat", label: L10n.pixelCatMode, isOn: usePixelCat) { usePixelCat.toggle() } - TabToggle(icon: "folder", label: L10n.groupByProject, isOn: showGrouped) { showGrouped.toggle() } + TabToggle(icon: "folder", label: L10n.groupByProject, isOn: showGrouped) { + showGrouped.toggle() } } - // Notch customization — theme, font size, visibility, - // hardware mode, and the live edit entry button. + // Notch customization — theme, buddy style, font size, + // visibility, hardware mode, and the live edit entry button. SettingsCard(title: L10n.notchSectionHeader) { NotchCustomizationSettingsView() } @@ -563,7 +1084,7 @@ private struct CodeLightTab: View { : "iphone.slash") .font(.system(size: 14)) .foregroundColor(syncManager.isEnabled - ? Theme.sidebarFill + ? Theme.accent : Color.white.opacity(0.4)) .frame(width: 18) @@ -601,7 +1122,7 @@ private struct CodeLightTab: View { .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 7) - .fill(Theme.sidebarFill) + .fill(Theme.accent) ) } .buttonStyle(.plain) @@ -792,7 +1313,7 @@ private struct AboutTab: View { .padding(.vertical, 8) .background( RoundedRectangle(cornerRadius: 8) - .fill(Theme.sidebarFill) + .fill(Theme.accent) ) } } @@ -896,7 +1417,7 @@ private struct CmuxConnectionTab: View { .foregroundColor(.black) .padding(.horizontal, 12) .padding(.vertical, 8) - .background(RoundedRectangle(cornerRadius: 8).fill(Theme.sidebarFill)) + .background(RoundedRectangle(cornerRadius: 8).fill(Theme.accent)) } .buttonStyle(.plain) .disabled(testState == .sending) diff --git a/ClaudeIslandTests/NotchCustomizationStoreTests.swift b/ClaudeIslandTests/NotchCustomizationStoreTests.swift index eda077bc..fdff6f89 100644 --- a/ClaudeIslandTests/NotchCustomizationStoreTests.swift +++ b/ClaudeIslandTests/NotchCustomizationStoreTests.swift @@ -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) } } diff --git a/ClaudeIslandTests/NotchCustomizationTests.swift b/ClaudeIslandTests/NotchCustomizationTests.swift index fb66a7f6..505f5455 100644 --- a/ClaudeIslandTests/NotchCustomizationTests.swift +++ b/ClaudeIslandTests/NotchCustomizationTests.swift @@ -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() { diff --git a/ClaudeIslandTests/NotchThemeTests.swift b/ClaudeIslandTests/NotchThemeTests.swift index 3c5f3dff..a854a738 100644 --- a/ClaudeIslandTests/NotchThemeTests.swift +++ b/ClaudeIslandTests/NotchThemeTests.swift @@ -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") } }