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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions GhosttyTabs.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@
D1000010A1B2C3D4E5F60800 /* ThemeTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000011A1B2C3D4E5F60800 /* ThemeTokens.swift */; };
D1000020A1B2C3D4E5F60800 /* ThemeStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000021A1B2C3D4E5F60800 /* ThemeStore.swift */; };
D1000030A1B2C3D4E5F60800 /* ThemeStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000031A1B2C3D4E5F60800 /* ThemeStoreTests.swift */; };
D1000040A1B2C3D4E5F60800 /* AppearanceSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000041A1B2C3D4E5F60800 /* AppearanceSection.swift */; };
D1000050A1B2C3D4E5F60800 /* BuiltInThemes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000051A1B2C3D4E5F60800 /* BuiltInThemes.swift */; };
/* End PBXBuildFile section */

/* Begin PBXCopyFilesBuildPhase section */
Expand Down Expand Up @@ -315,6 +317,8 @@
D1000011A1B2C3D4E5F60800 /* ThemeTokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeTokens.swift; sourceTree = "<group>"; };
D1000021A1B2C3D4E5F60800 /* ThemeStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeStore.swift; sourceTree = "<group>"; };
D1000031A1B2C3D4E5F60800 /* ThemeStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeStoreTests.swift; sourceTree = "<group>"; };
D1000041A1B2C3D4E5F60800 /* AppearanceSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceSection.swift; sourceTree = "<group>"; };
D1000051A1B2C3D4E5F60800 /* BuiltInThemes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuiltInThemes.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -488,6 +492,8 @@
D1000001A1B2C3D4E5F60800 /* SuperghostTheme.swift */,
D1000011A1B2C3D4E5F60800 /* ThemeTokens.swift */,
D1000021A1B2C3D4E5F60800 /* ThemeStore.swift */,
D1000041A1B2C3D4E5F60800 /* AppearanceSection.swift */,
D1000051A1B2C3D4E5F60800 /* BuiltInThemes.swift */,
);
path = Sources;
sourceTree = "<group>";
Expand Down Expand Up @@ -796,6 +802,8 @@
D1000000A1B2C3D4E5F60800 /* SuperghostTheme.swift in Sources */,
D1000010A1B2C3D4E5F60800 /* ThemeTokens.swift in Sources */,
D1000020A1B2C3D4E5F60800 /* ThemeStore.swift in Sources */,
D1000040A1B2C3D4E5F60800 /* AppearanceSection.swift in Sources */,
D1000050A1B2C3D4E5F60800 /* BuiltInThemes.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
225 changes: 225 additions & 0 deletions Sources/AppearanceSection.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
import SwiftUI
import AppKit

// `AppearanceSection` is the new Settings → Appearance UI surface. Milestone 2 ships the
// walking skeleton — header + live preview + preset strip with two themes. Milestones 3, 6,
// and 7 fill in per-mode overrides, global controls, color picker, and library browser.
//
// Per plan §2.4 this is a section that lives inside the existing `SettingsView` ScrollView,
// using shared `SettingsCard` / `SettingsCardRow` / `SettingsSectionHeader` components.
// We define this in its own file because at ~600 LOC it doesn't belong inside cmuxApp.swift,
// even though the convention for other sections is inline.
//
// The section reads from `ThemeStore.shared` via `@EnvironmentObject`, which is injected at
// the `SettingsRootView` boundary. AppKit chrome (sidebar background, etc.) responds because
// `ThemeStore.applyTheme(_:)` writes through to the legacy `sidebarTintHex*` UserDefaults
// keys that `SidebarBackdrop` already observes; the proper migration of those readers to
// direct `theme.tokens.*` reads is Milestone 5.
struct AppearanceSection: View {
@EnvironmentObject private var themeStore: ThemeStore

var body: some View {
VStack(alignment: .leading, spacing: 14) {
SettingsAppearanceSectionHeader()

// Live preview — a miniature Superghost layout. Reacts to ThemeStore changes
// through @EnvironmentObject. The preview chrome (mini-sidebar, mini-terminal)
// pulls from `themeStore.activeTheme.tokens`; the proof that the architecture
// works end-to-end is that this AND the real app sidebar both recolor in the
// same render frame when a preset is clicked.
AppearanceLivePreview(theme: themeStore.activeTheme)
.padding(.top, 4)
.padding(.bottom, 8)

// Preset strip — two themes for Milestone 2 (handoff §2's curated set in
// M4 grows this to 8–12). Click swaps `themeStore.activeTheme` and propagates
// through the cache + UserDefaults bridge.
AppearancePresetStrip(
presets: BuiltInThemes.all,
activeId: themeStore.activeTheme.id,
onSelect: { theme in
themeStore.applyTheme(theme)
}
)
}
.id(SettingsNavigationTarget.appearance) // navigation target — routed via `proxy.scrollTo` in SettingsView
}
}

// Stable identifier (kept around in case a future caller needs to reference the section
// outside the SettingsNavigationTarget enum).
enum AppearanceSectionAnchor {
static let id = "settings.section.appearance"
}

private struct SettingsAppearanceSectionHeader: View {
var body: some View {
Text(String(localized: "settings.section.appearance", defaultValue: "Appearance"))
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.secondary)
.padding(.leading, 2)
.padding(.bottom, -2)
}
}

// MARK: - Live preview

private struct AppearanceLivePreview: View {
let theme: SuperghostTheme

var body: some View {
// The preview is a stack of two stripes — sidebar on the left, terminal pane on the
// right — sized to mirror the actual Superghost workspace at a glance. Picking a
// preset recolors both stripes in one render frame; that's the M2 acceptance demo.
HStack(spacing: 0) {
// Mini-sidebar
VStack(alignment: .leading, spacing: 6) {
miniSidebarRow(label: "main", selected: true)
miniSidebarRow(label: "feature/themes", selected: false)
miniSidebarRow(label: "test/coverage", selected: false)
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 12)
.frame(width: 140, alignment: .topLeading)
.background(Color(nsColor: theme.tokens.cardSurface))

// Mini-terminal
VStack(alignment: .leading, spacing: 4) {
Text("$ superghost --version")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(Color(nsColor: theme.tokens.inkBody))
Text("0.1.0")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(Color(nsColor: theme.tokens.inkMuted))
Text("$ _")
.font(.system(size: 11, design: .monospaced))
.foregroundColor(Color(nsColor: theme.cursorColor))
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(Color(nsColor: theme.backgroundColor))
}
.frame(height: 140)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color(nsColor: theme.tokens.hairlineBorder), lineWidth: 1)
)
.accessibilityElement(children: .ignore)
.accessibilityLabel(
String(
localized: "settings.appearance.preview.label",
defaultValue: "Live preview of the \(theme.name) theme"
)
)
}

@ViewBuilder
private func miniSidebarRow(label: String, selected: Bool) -> some View {
HStack(spacing: 6) {
Circle()
.fill(Color(nsColor: selected ? theme.tokens.accentInline : theme.tokens.inkMuted))
.frame(width: 6, height: 6)
Text(label)
.font(.system(size: 11, weight: selected ? .semibold : .regular))
.foregroundColor(Color(nsColor: selected ? theme.tokens.inkHeadline : theme.tokens.inkBody))
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 5, style: .continuous)
.fill(Color(nsColor: selected ? theme.tokens.liftedSurface : .clear))
)
}
}

// MARK: - Preset strip

private struct AppearancePresetStrip: View {
let presets: [SuperghostTheme]
let activeId: String
let onSelect: (SuperghostTheme) -> Void

var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 10) {
ForEach(presets, id: \.id) { theme in
AppearancePresetCard(
theme: theme,
isActive: theme.id == activeId,
onSelect: { onSelect(theme) }
)
}
}
.padding(.vertical, 4)
}
}
}

private struct AppearancePresetCard: View {
let theme: SuperghostTheme
let isActive: Bool
let onSelect: () -> Void

var body: some View {
Button(action: onSelect) {
VStack(alignment: .leading, spacing: 8) {
// Color preview row — chrome surface + accent + ink, the tokens that most
// visibly distinguish themes at a glance.
HStack(spacing: 4) {
swatch(theme.tokens.cardSurface)
swatch(theme.backgroundColor)
swatch(theme.tokens.accentInline)
swatch(theme.tokens.inkBody)
}
Text(theme.name)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.primary)
.lineLimit(1)
Text(modeLabel(theme.mode))
.font(.system(size: 11))
.foregroundColor(.secondary)
}
.padding(10)
.frame(width: 144, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(nsColor: NSColor.controlBackgroundColor).opacity(0.5))
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(
isActive ? Color.accentColor : Color(nsColor: NSColor.separatorColor).opacity(0.5),
lineWidth: isActive ? 2 : 1
)
)
}
.buttonStyle(.plain)
.accessibilityLabel(theme.name)
.accessibilityAddTraits(isActive ? [.isSelected] : [])
}

private func swatch(_ color: NSColor) -> some View {
RoundedRectangle(cornerRadius: 3, style: .continuous)
.fill(Color(nsColor: color))
.frame(width: 22, height: 22)
.overlay(
RoundedRectangle(cornerRadius: 3, style: .continuous)
.stroke(Color.black.opacity(0.08), lineWidth: 0.5)
)
}

private func modeLabel(_ mode: ThemeMode) -> String {
switch mode {
case .light:
return String(localized: "settings.appearance.preset.mode.light", defaultValue: "Light mode")
case .dark:
return String(localized: "settings.appearance.preset.mode.dark", defaultValue: "Dark mode")
}
}
}
135 changes: 135 additions & 0 deletions Sources/BuiltInThemes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import Foundation
import AppKit

// Curated built-in themes shipped with Superghost. The Milestone 2 walking skeleton needs
// at least two themes (one dark, one light) to prove the architecture; Milestone 4 grows
// this to the full 8–12 set per handoff §2 and validates contrast on every chrome token.
//
// Color values for each theme come from:
// - Tokyo Night: handoff §2 "Token Architecture", values are the published Tokyo Night
// palette (https://github.com/folke/tokyonight.nvim) with chrome derived for the
// specific ladder Matt's prototype calls out.
// - Catppuccin Latte: published Catppuccin Latte palette
// (https://github.com/catppuccin/catppuccin#-palette) with chrome chosen to mirror the
// Tokyo Night ladder in the inverse direction.
//
// Both are *fully specified* (not derived). That's the contract M4 codifies for every
// built-in: ship every chrome token with a hand-chosen value so the curated set is the
// reference for what "good" looks like.
enum BuiltInThemes {
static var all: [SuperghostTheme] {
[tokyoNight, catppuccinLatte]
}

static let tokyoNight = SuperghostTheme(
id: "tokyo-night",
name: "Tokyo Night",
mode: .dark,
source: .builtIn,
backgroundColor: NSColor(hex: "#1a1b26")!,
foregroundColor: NSColor(hex: "#c0caf5")!,
cursorColor: NSColor(hex: "#7aa2f7")!,
cursorTextColor: NSColor(hex: "#1a1b26")!,
selectionBackground: NSColor(hex: "#283457")!,
selectionForeground: NSColor(hex: "#c0caf5")!,
palette: [
0: NSColor(hex: "#15161e")!,
1: NSColor(hex: "#f7768e")!,
2: NSColor(hex: "#9ece6a")!,
3: NSColor(hex: "#e0af68")!,
4: NSColor(hex: "#7aa2f7")!,
5: NSColor(hex: "#bb9af7")!,
6: NSColor(hex: "#7dcfff")!,
7: NSColor(hex: "#a9b1d6")!,
8: NSColor(hex: "#414868")!,
9: NSColor(hex: "#f7768e")!,
10: NSColor(hex: "#9ece6a")!,
11: NSColor(hex: "#e0af68")!,
12: NSColor(hex: "#7aa2f7")!,
13: NSColor(hex: "#bb9af7")!,
14: NSColor(hex: "#7dcfff")!,
15: NSColor(hex: "#c0caf5")!
],
cardSurface: NSColor(hex: "#1a1b26")!,
liftedSurface: NSColor(hex: "#1f2335")!,
hairlineBorder: NSColor(hex: "#2f334d")!,
hairlineBorderHover: NSColor(hex: "#3d4263")!,
inkHeadline: NSColor(hex: "#c0caf5")!,
inkBody: NSColor(hex: "#a9b1d6")!,
// Bumped from the handoff's #565f89 (R1 in the plan — fails AA on caption sizes).
// The plan defers this to M6; we ship the safe value from the start so the walking
// skeleton already meets AA. Documented in the plan-fix log.
inkMuted: NSColor(hex: "#737aa2")!,
inkCaption: NSColor(hex: "#737aa2")!,
accentSolid: NSColor(hex: "#3d59a1")!,
accentInline: NSColor(hex: "#7aa2f7")!,
semanticSuccess: NSColor(hex: "#1a2b25")!,
semanticWarning: NSColor(hex: "#3d3328")!,
semanticDanger: NSColor(hex: "#3c2c3a")!,
semanticInfo: NSColor(hex: "#16263a")!,
semanticSkill: NSColor(hex: "#3d4263")!,
translucentSidebar: true,
contrastBoost: 68
)

static let catppuccinLatte = SuperghostTheme(
id: "catppuccin-latte",
name: "Catppuccin Latte",
mode: .light,
source: .builtIn,
backgroundColor: NSColor(hex: "#eff1f5")!,
foregroundColor: NSColor(hex: "#4c4f69")!,
cursorColor: NSColor(hex: "#1e66f5")!,
cursorTextColor: NSColor(hex: "#eff1f5")!,
selectionBackground: NSColor(hex: "#bcc0cc")!,
selectionForeground: NSColor(hex: "#4c4f69")!,
palette: [
0: NSColor(hex: "#5c5f77")!,
1: NSColor(hex: "#d20f39")!,
2: NSColor(hex: "#40a02b")!,
3: NSColor(hex: "#df8e1d")!,
4: NSColor(hex: "#1e66f5")!,
5: NSColor(hex: "#ea76cb")!,
6: NSColor(hex: "#179299")!,
7: NSColor(hex: "#acb0be")!,
8: NSColor(hex: "#6c6f85")!,
9: NSColor(hex: "#d20f39")!,
10: NSColor(hex: "#40a02b")!,
11: NSColor(hex: "#df8e1d")!,
12: NSColor(hex: "#1e66f5")!,
13: NSColor(hex: "#ea76cb")!,
14: NSColor(hex: "#179299")!,
15: NSColor(hex: "#bcc0cc")!
],
cardSurface: NSColor(hex: "#e6e9ef")!,
liftedSurface: NSColor(hex: "#dce0e8")!,
hairlineBorder: NSColor(hex: "#ccd0da")!,
hairlineBorderHover: NSColor(hex: "#bcc0cc")!,
inkHeadline: NSColor(hex: "#4c4f69")!,
inkBody: NSColor(hex: "#5c5f77")!,
inkMuted: NSColor(hex: "#6c6f85")!,
inkCaption: NSColor(hex: "#7c7f93")!,
accentSolid: NSColor(hex: "#1e66f5")!,
accentInline: NSColor(hex: "#1e66f5")!,
semanticSuccess: NSColor(hex: "#d9eedb")!,
semanticWarning: NSColor(hex: "#f5e6c8")!,
semanticDanger: NSColor(hex: "#f5d6dc")!,
semanticInfo: NSColor(hex: "#d7e3fc")!,
semanticSkill: NSColor(hex: "#e0e3ef")!,
translucentSidebar: true,
contrastBoost: 60
)

// Resolve a preset by id. Used by `ThemeStore` and `AppearanceSection`.
static func preset(withId id: String) -> SuperghostTheme? {
all.first(where: { $0.id == id })
}

// Default theme to apply for a given color scheme when the user hasn't picked one yet.
static func defaultPreset(for mode: ThemeMode) -> SuperghostTheme {
switch mode {
case .dark: return tokyoNight
case .light: return catppuccinLatte
}
}
}
Loading
Loading