fix: render light palettes with matching appearance

This commit is contained in:
2026-10-22 16:47:58 +02:00
parent 1b171c7b52
commit c0a17f5efe
16 changed files with 467 additions and 27 deletions
+2 -2
View File
@@ -55,11 +55,11 @@ final class ThemeObservation {
nonisolated(unsafe) private let token: NSObjectProtocol
init(_ apply: @escaping @MainActor () -> Void) {
apply()
ThemePalette.appearance.performAsCurrentDrawingAppearance(apply)
token = NotificationCenter.default.addObserver(
forName: ThemePalette.didChangeNotification, object: nil, queue: .main
) { _ in
MainActor.assumeIsolated { apply() }
MainActor.assumeIsolated { ThemePalette.appearance.performAsCurrentDrawingAppearance(apply) }
}
}
@@ -94,6 +94,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
ActionEntry(title: "Export as Markdown…", symbol: "square.and.arrow.up")
]
view = ActionsPanelView()
view.appearance = ThemePalette.appearance
view.wantsLayer = true
view.layer?.shadowColor = NSColor.black.cgColor
view.layer?.shadowOpacity = 0.35
@@ -215,6 +216,7 @@ final class ApplicationActionsController: NSViewController, NSSearchFieldDelegat
updateSelection()
themeObservation = ThemeObservation { [weak self, weak content] in
guard let self else { return }
self.view.appearance = ThemePalette.appearance
self.search.applyTheme()
self.applicationLabel.textColor = ThemePalette.secondaryText
self.empty.textColor = ThemePalette.secondaryText
+29 -1
View File
@@ -10,6 +10,28 @@ struct ColorScheme {
var glow: NSColor
var keyBackground: NSColor
var error: NSColor
enum Variant: String {
case light, dark
}
var variant: Variant? = nil
var isLight: Bool {
if let variant { return variant == .light }
return Self.relativeLuminance(background) > Self.relativeLuminance(text)
}
static func relativeLuminance(_ color: NSColor) -> CGFloat {
let rgb = color.usingColorSpace(.sRGB)!
return relativeLuminance(red: rgb.redComponent, green: rgb.greenComponent, blue: rgb.blueComponent)
}
static func relativeLuminance(red: CGFloat, green: CGFloat, blue: CGFloat) -> CGFloat {
func linear(_ value: CGFloat) -> CGFloat {
value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
}
return 0.2126 * linear(red) + 0.7152 * linear(green) + 0.0722 * linear(blue)
}
static let sunset = ColorScheme(
background: NSColor(srgbRed: 0.12, green: 0.09, blue: 0.08, alpha: 1),
@@ -49,7 +71,13 @@ struct ColorScheme {
if theme == "tint" {
let themeURL = directory.appendingPathComponent("theme.json")
if let generated = try readObject(at: themeURL, missingAllowed: false) {
try validateKeys(generated, allowed: ["colors"], at: themeURL, prefix: "")
try validateKeys(generated, allowed: ["variant", "colors"], at: themeURL, prefix: "")
if let value = generated["variant"] {
guard let name = value as? String, let variant = Variant(rawValue: name) else {
throw ColorSchemeError(path: themeURL.path, reason: "variant must be \"light\" or \"dark\".")
}
result.variant = variant
}
try result.applyColors(from: generated, at: themeURL)
}
}
@@ -6,6 +6,7 @@ final class LauncherAlignmentGuides {
private let view = AlignmentGuideView()
private lazy var overlay: NSPanel = {
let panel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
panel.appearance = ThemePalette.appearance
panel.backgroundColor = .clear
panel.isOpaque = false
panel.hasShadow = false
@@ -20,6 +21,7 @@ final class LauncherAlignmentGuides {
func begin(for window: NSWindow) {
guard !dragging, window.screen != nil else { return }
overlay.appearance = ThemePalette.appearance
dragging = true
update(for: window)
overlay.level = window.level
@@ -66,6 +68,7 @@ private final class AlignmentGuideView: NSView {
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
themeObservation = ThemeObservation { [weak self] in
self?.window?.appearance = ThemePalette.appearance
self?.muted = ThemePalette.accent.withAlphaComponent(0.4).cgColor
self?.aligned = ThemePalette.accent.withAlphaComponent(0.95).cgColor
self?.needsDisplay = true
+66 -11
View File
@@ -2,10 +2,15 @@ import AppKit
@MainActor
enum ThemePalette {
@MainActor
struct State {
let scheme: ColorScheme
var error: String?
var usesFallback: Bool
let isLight: Bool
let appearance: NSAppearance
let secondaryText: NSColor
let glowStrength: CGFloat
let background: NSColor
let selectionFill: NSColor
let selectionBorder: NSColor
@@ -15,11 +20,58 @@ enum ThemePalette {
self.scheme = scheme
self.error = error
self.usesFallback = usesFallback
background = scheme.background.withAlphaComponent(0.55)
let light = scheme.isLight
isLight = light
appearance = NSAppearance(named: light ? .aqua : .darkAqua)!
glowStrength = light ? 0.35 : 1
background = scheme.background.withAlphaComponent(light ? 0.82 : 0.55)
secondaryText = light ? Self.readableSecondaryText(scheme) : scheme.secondaryText
selectionFill = scheme.accent.withAlphaComponent(0.12)
selectionBorder = scheme.accent.withAlphaComponent(0.30)
keyBackground = scheme.keyBackground.withAlphaComponent(0.5)
}
private static func readableSecondaryText(_ scheme: ColorScheme) -> NSColor {
let background = scheme.background.usingColorSpace(.sRGB)!
let accent = scheme.accent.usingColorSpace(.sRGB)!
let glow = scheme.glow.usingColorSpace(.sRGB)!
// Black behind the translucent fill bounds every desktop. Each gradient may
// darken a channel independently, including the third, dimmed accent glow.
func darkestChannel(_ base: CGFloat, _ accent: CGFloat, _ glow: CGFloat) -> CGFloat {
var value = base * 0.82
value = min(value, value * (1 - 0.26 * 0.35) + glow * 0.26 * 0.35)
value = min(value, value * (1 - 0.18 * 0.35) + accent * 0.18 * 0.35)
return min(value, value * (1 - 0.18 * 0.35 * 0.45) + accent * 0.18 * 0.35 * 0.45)
}
let surface = ColorScheme.relativeLuminance(
red: darkestChannel(background.redComponent, accent.redComponent, glow.redComponent),
green: darkestChannel(background.greenComponent, accent.greenComponent, glow.greenComponent),
blue: darkestChannel(background.blueComponent, accent.blueComponent, glow.blueComponent))
// A small margin covers 8-bit surface rounding.
let ceiling = max(0, (surface + 0.05) / 4.55 - 0.05)
guard ColorScheme.relativeLuminance(scheme.secondaryText) > ceiling else {
return scheme.secondaryText
}
let source = scheme.secondaryText.usingColorSpace(.sRGB)!
let target = (ColorScheme.relativeLuminance(scheme.text) <= ceiling ? scheme.text : .black)
.usingColorSpace(.sRGB)!
func component(_ from: CGFloat, _ to: CGFloat, _ fraction: CGFloat) -> CGFloat {
from + (to - from) * fraction
}
var lower: CGFloat = 0
var upper: CGFloat = 1
for _ in 0..<24 {
let fraction = (lower + upper) / 2
let luminance = ColorScheme.relativeLuminance(
red: component(source.redComponent, target.redComponent, fraction),
green: component(source.greenComponent, target.greenComponent, fraction),
blue: component(source.blueComponent, target.blueComponent, fraction))
if luminance > ceiling { lower = fraction } else { upper = fraction }
}
return NSColor(srgbRed: component(source.redComponent, target.redComponent, upper),
green: component(source.greenComponent, target.greenComponent, upper),
blue: component(source.blueComponent, target.blueComponent, upper), alpha: 1)
}
}
static var loaded: State = {
@@ -35,13 +87,16 @@ enum ThemePalette {
static let didChangeNotification = Notification.Name("ConjureThemePaletteDidChange")
static var configurationError: String? { loaded.error }
static var isLight: Bool { loaded.isLight }
static var appearance: NSAppearance { loaded.appearance }
static var background: NSColor { loaded.background }
static var text: NSColor { loaded.scheme.text }
static var secondaryText: NSColor { loaded.scheme.secondaryText }
static var secondaryText: NSColor { loaded.secondaryText }
static var accent: NSColor { loaded.scheme.accent }
static var selectionFill: NSColor { loaded.selectionFill }
static var selectionBorder: NSColor { loaded.selectionBorder }
static var glow: NSColor { loaded.scheme.glow }
static var glowStrength: CGFloat { loaded.glowStrength }
static var keyBackground: NSColor { loaded.keyBackground }
static var error: NSColor { loaded.scheme.error }
@@ -66,7 +121,7 @@ enum ThemePalette {
guard scheme.background != previous.background || scheme.text != previous.text
|| scheme.secondaryText != previous.secondaryText || scheme.accent != previous.accent
|| scheme.glow != previous.glow || scheme.keyBackground != previous.keyBackground
|| scheme.error != previous.error else {
|| scheme.error != previous.error || scheme.variant != previous.variant else {
loaded.error = nil
loaded.usesFallback = false
return false
@@ -88,13 +143,13 @@ enum ThemePalette {
final class LauncherBackground: NSView {
weak var canvas: NSView?
private var amber = NSGradient(colors: [
ThemePalette.accent.withAlphaComponent(0.18),
ThemePalette.accent.withAlphaComponent(0.065),
ThemePalette.accent.withAlphaComponent(0.18 * ThemePalette.glowStrength),
ThemePalette.accent.withAlphaComponent(0.065 * ThemePalette.glowStrength),
ThemePalette.accent.withAlphaComponent(0),
])!
private var ember = NSGradient(colors: [
ThemePalette.glow.withAlphaComponent(0.26),
ThemePalette.glow.withAlphaComponent(0.10),
ThemePalette.glow.withAlphaComponent(0.26 * ThemePalette.glowStrength),
ThemePalette.glow.withAlphaComponent(0.10 * ThemePalette.glowStrength),
ThemePalette.glow.withAlphaComponent(0),
])!
@@ -115,13 +170,13 @@ final class LauncherBackground: NSView {
@objc private func paletteChanged() {
amber = NSGradient(colors: [
ThemePalette.accent.withAlphaComponent(0.18),
ThemePalette.accent.withAlphaComponent(0.065),
ThemePalette.accent.withAlphaComponent(0.18 * ThemePalette.glowStrength),
ThemePalette.accent.withAlphaComponent(0.065 * ThemePalette.glowStrength),
ThemePalette.accent.withAlphaComponent(0),
])!
ember = NSGradient(colors: [
ThemePalette.glow.withAlphaComponent(0.26),
ThemePalette.glow.withAlphaComponent(0.10),
ThemePalette.glow.withAlphaComponent(0.26 * ThemePalette.glowStrength),
ThemePalette.glow.withAlphaComponent(0.10 * ThemePalette.glowStrength),
ThemePalette.glow.withAlphaComponent(0),
])!
needsDisplay = true
@@ -81,7 +81,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true
window.animationBehavior = .none
window.appearance = NSAppearance(named: .darkAqua)
window.appearance = ThemePalette.appearance
window.backgroundColor = .clear
window.isOpaque = false
window.hasShadow = true
@@ -403,6 +403,8 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
launcherViews = [header, sectionLabel, scrollView, emptyLabel, footer]
themeObservation = ThemeObservation { [weak self, weak content] in
guard let self else { return }
self.window?.appearance = ThemePalette.appearance
self.window?.attachedSheet?.appearance = ThemePalette.appearance
self.searchField.applyTheme()
(self.searchField.currentEditor() as? NSTextView)?.insertionPointColor = ThemePalette.accent
self.searchSymbol.contentTintColor = ThemePalette.accent
@@ -767,6 +769,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
dismiss()
} catch {
let alert = NSAlert(error: error)
alert.window.appearance = ThemePalette.appearance
alert.messageText = "Couldn’t quit \(application.name)"
if let window { alert.beginSheetModal(for: window) }
}
@@ -788,6 +791,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
dismiss(restoringFocus: false)
} catch {
let alert = NSAlert(error: error)
alert.window.appearance = ThemePalette.appearance
alert.messageText = "Couldn’t open \(application.name)"
if let window {
await alert.beginSheetModal(for: window)
@@ -858,6 +862,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
if dismissOnSuccess { dismiss() }
} catch {
let alert = NSAlert(error: error)
alert.window.appearance = ThemePalette.appearance
alert.messageText = "Couldn’t run \(Application(command: command).name)"
if let window, window.isVisible {
await alert.beginSheetModal(for: window)
@@ -939,7 +944,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
windowSize: previousFrame.size, visibleFrame: window.screen?.visibleFrame ?? previousFrame)
window.styleMask = [.borderless]
window.minSize = .zero
window.appearance = NSAppearance(named: .darkAqua)
window.appearance = ThemePalette.appearance
window.setFrame(frame, display: true)
if let material = content as? NSVisualEffectView { roundMaterialCorners(material, radius: 24) }
}
@@ -980,7 +985,7 @@ final class LauncherWindowController: NSWindowController, NSWindowDelegate, NSSe
if command == .notes {
let savedSize = defaults.string(forKey: Self.notesSizeKey).map(NSSizeFromString)
?? NSSize(width: 570, height: 404)
window?.appearance = nil
window?.appearance = ThemePalette.appearance
window?.styleMask = [.titled, .closable, .resizable, .fullSizeContentView]
window?.minSize = NSSize(width: 570, height: 260)
window?.standardWindowButton(.miniaturizeButton)?.isHidden = true
+2
View File
@@ -345,6 +345,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
do {
let export = try NoteMarkdownExport(contents: editor.documentContents)
let panel = NSSavePanel()
panel.appearance = ThemePalette.appearance
panel.title = "Export as Markdown"
panel.prompt = "Export"
panel.canCreateDirectories = true
@@ -376,6 +377,7 @@ final class NotesController: NSViewController, NSTextViewDelegate {
guard let window = view.window, window.attachedSheet == nil, let id = selectedID else { return }
actions.close(animated: false)
let alert = NSAlert()
alert.window.appearance = ThemePalette.appearance
alert.alertStyle = .warning
alert.messageText = "Delete “\(noteTitle.stringValue)”?"
alert.informativeText = "This permanently deletes the note and its embedded images. This cannot be undone."
+11 -5
View File
@@ -516,11 +516,17 @@ final class NotesTextView: NSTextView {
super.didChangeText()
}
private static let highlighter: Highlighter = {
let value = Highlighter()!
value.setTheme("atom-one-dark", withFont: "Menlo-Regular", ofSize: 15)
return value
}()
private static let syntaxHighlighter = Highlighter()!
private static var highlightedAsLight: Bool?
private static var highlighter: Highlighter {
let isLight = ThemePalette.isLight
if highlightedAsLight != isLight {
syntaxHighlighter.setTheme(isLight ? "atom-one-light" : "atom-one-dark",
withFont: "Menlo-Regular", ofSize: 15)
highlightedAsLight = isLight
}
return syntaxHighlighter
}
// ponytail: style all paragraphs; dirty-paragraph styling if large notes make editing slow.
func applyDocumentStyle() {
guard !styling, let storage = textStorage else { return }
@@ -32,7 +32,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
defer: false
)
window.title = "Conjure Settings"
window.appearance = NSAppearance(named: .darkAqua)
window.appearance = ThemePalette.appearance
window.titlebarAppearsTransparent = true
window.backgroundColor = .clear
window.isOpaque = false
@@ -201,6 +201,8 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
}
themeObservation = ThemeObservation { [weak self] in
guard let self else { return }
self.window?.appearance = ThemePalette.appearance
self.window?.attachedSheet?.appearance = ThemePalette.appearance
for label in [heading, loginHeading, translationHeading] { label.textColor = ThemePalette.text }
for label in [instructions, self.statusLabel, spotlight] { label.textColor = ThemePalette.secondaryText }
self.errorLabel.textColor = ThemePalette.error
@@ -234,6 +236,7 @@ final class SettingsWindowController: NSWindowController, NSWindowDelegate {
setUninstallRecoveryReport(result)
} else { result = report }
let alert = NSAlert()
alert.window.appearance = ThemePalette.appearance
alert.messageText = result.needsRecovery ? "Recovery needs attention" : "Recovery complete"
alert.informativeText = result.messages.isEmpty ? "No pending files need recovery." : result.messages.joined(separator: "\n")
await alert.beginSheetModal(for: window)
@@ -321,6 +321,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
guard !urls.isEmpty else { return }
let running = runningApplications()
let alert = NSAlert()
alert.window.appearance = ThemePalette.appearance
alert.alertStyle = .warning
alert.messageText = "Uninstall \(application.name)?"
alert.informativeText = "Move \(urls.count) selected files and folders to Trash."
@@ -380,6 +381,7 @@ final class UninstallReviewController: NSViewController, NSSearchFieldDelegate,
var canRecover = allowRecovery
while true {
let alert = NSAlert()
alert.window.appearance = ThemePalette.appearance
alert.messageText = title
alert.informativeText = message
alert.addButton(withTitle: "OK")