mirror of
https://github.com/MioMioOS/MioIsland
synced 2026-04-21 13:37:26 +00:00
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:
parent
daae3ecba0
commit
b2f3a3554f
12 changed files with 1087 additions and 312 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 : [])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue