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
+4 -2
View File
@@ -150,7 +150,7 @@ error = "#ff5370"
Every color is optional and must be an opaque `#RRGGBB` value. `accent` colors selections, borders, carets, and the primary background glows; `glow` colors the secondary background glow. `keyBackground` colors application-number shortcut badges, and `error` colors errors and destructive actions. Overrides apply throughout the launcher, tools, popups, and Settings wherever those shared colors are used.
Only colors change. Frosted glass, glow positions and sizes, opacity, selection treatment, typography, and layout stay fixed. Native controls, application/emoji icons, and Notes code-syntax highlighting retain their existing styling.
Only colors are configurable. Frosted glass, glow positions and sizes, selection treatment, typography, and layout keep the same design. Native controls and Notes syntax highlighting follow the palette's light/dark appearance. Light palettes use a denser translucent base, softer glows, and contrast-corrected secondary text so placeholders and muted labels remain readable; the dark rendering is unchanged. Application and emoji icons are not recolored.
### Follow tint
@@ -165,6 +165,8 @@ accent = "#82aaff" # Optional personal override
Precedence is personal `[colors]` → tint-generated colors → built-in sunset colors. Omit `[colors]` to follow tint without overrides. Omit `theme`, or set it to `"sunset"` before any table, to ignore the generated palette. tint never overwrites `config.toml`; edit that file, not the generated `theme.json`. Both processes must use the same configuration directory.
The generated palette includes tint's `light`/`dark` variant, which selects the matching native appearance for the launcher, Notes, Settings, popups, and sheets. For manually configured colors or a palette without variant metadata, Conjure infers appearance by comparing background and foreground luminance. There is no separate appearance, opacity, or glow-strength setting. Low-contrast secondary colors are adjusted only in the light-mode presentation; source palette files are not rewritten.
Conjure watches `config.toml` and the generated `theme.json` while running. Applying a tint theme, including automatic system light/dark switches, updates existing windows and tools without restarting or clearing searches, notes, selections, or translation results. Personal configuration edits also apply live. In-place saves and atomic file replacements are supported, including configuration directories created after launch. Invalid configuration at startup falls back to sunset and reports a file-specific error in Settings; invalid live edits keep the last valid palette and log the diagnostic, then recover automatically when the file is fixed.
## System theme
@@ -179,7 +181,7 @@ Commands explicitly declare `Application.Command.presentsView`. The shared dispa
Search for **Conjure Notes**, identified by its centered 📔 icon, or assign it a global shortcut in Settings. Notes starts with a compact 570 × 404 layout inspired by Raycast Notes, with rounded corners and a centered muted note title, while using Conjure’s shared warm gradient background, cream text, and amber list markers and caret. Drag a window edge or corner to resize it; Notes remembers its size across reopening and app restarts, independently of other launcher views. The minimum size is 570 × 260, and restored dimensions fit the current screen’s visible area. The title bar offers Close; the bottom-right action pill offers **Browse Notes** and **Actions**, with **New Note** in Actions and on Command-N. Browse Notes (Command-P) opens Conjure’s styled, scrollable searchable picker upwards from the footer, shared with translation language selection—not a native menu. Fuzzy-search note titles, then select with Up/Down, Control-J/K, and Return or by clicking a note. A checkmark marks the current note, which is selected and scrolled into view on opening. Reopening Notes restores the last selection. Each note’s first nonempty line becomes its title; duplicate titles remain independently selectable.
Notes uses a standard macOS window with system-inherited appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, while retaining Conjure’s warm palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading’s size, and code-block contents remain literal. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless with their existing dark appearance.
Notes uses a standard macOS window matching Conjure's palette appearance and only the native Close button visible; minimize and zoom are hidden. The editor uses 15-point monospaced body text, including bold, italic, and code, with light/dark syntax highlighting following the selected palette. Heading sizes decrease from H1 through H6 (26, 23, 21, 19, 17, and 15 points); inline emphasis retains the heading's size, and code-block contents remain literal. Closing still saves first and keeps the window open if saving fails. Other launcher views remain borderless and use the same palette appearance.
Actions (Command-K) opens the same searchable popup style used throughout Conjure, with **Browse Notes** (Command-P), **New Note** (Command-N), and **Delete Note**. Delete Note asks for confirmation before permanently deleting the currently selected note and its embedded images. Delete is focused by default: Return confirms and Escape cancels; deletion cannot be undone. After deletion, Notes selects a remaining note or creates a fresh blank note if none remain. Save or deletion failures preserve the current note and editor buffer. Command-N creates a note while the editor or either popup is focused. Command-P toggles Browse Notes even while its title-bar controls are covered. Escape or clicking outside closes an open popup and returns focus to the editor without leaving Notes; another Escape dismisses the launcher. Control-J/K navigates only while a popup is open, preserving normal editor commands when it is closed.
+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")
@@ -71,6 +71,7 @@ func malformedColorConfigurationReportsItsPathAndReason() throws {
("colors.accent = 42", "colors.accent"),
("colors.accnet = '#123456'", "colors.accnet"),
("opacity = 0.5", "opacity"),
("variant = 'light'", "variant"),
("colors = []", "colors"),
("colors = false", "colors"),
("theme = 'unknown'", "theme"),
@@ -0,0 +1,194 @@
import AppKit
import Testing
@testable import Conjure
// Conjure roles generated by tint from base16-swamp-light.
private let swampLightTheme = ##"{"variant":"light","colors":{"background":"#f1e3d1","text":"#64513e","secondaryText":"#a0907d","accent":"#bf7979","glow":"#d09700","keyBackground":"#ddcebc","error":"#d09700"}}"##
@Test @MainActor
func generatedLightMetadataAndManualLuminanceChooseTheMatchingAppearance() throws {
let directory = try lightConfigurationDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
let theme = directory.appendingPathComponent("theme.json")
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
try Data(swampLightTheme.utf8).write(to: theme)
let light = try ColorScheme.load(from: directory)
#expect(light.isLight)
#expect(ThemePalette.State(scheme: light).appearance.name == .aqua)
// Metadata wins even when manual colors disagree with the generated variant.
try Data("theme = 'tint'\ncolors.background = '#000000'\ncolors.text = '#ffffff'\n".utf8)
.write(to: directory.appendingPathComponent("config.toml"))
#expect(try ColorScheme.load(from: directory).isLight)
try Data("colors.background = '#f1e3d1'\ncolors.text = '#64513e'\n".utf8)
.write(to: directory.appendingPathComponent("config.toml"))
let manual = try ColorScheme.load(from: directory)
#expect(manual.isLight)
#expect(ThemePalette.State(scheme: manual).appearance.name == .aqua)
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
try Data(swampLightTheme.replacingOccurrences(of: "\"variant\":\"light\",", with: "").utf8).write(to: theme)
#expect(try ColorScheme.load(from: directory).isLight)
try FileManager.default.removeItem(at: directory.appendingPathComponent("config.toml"))
#expect(try !ColorScheme.load(from: directory).isLight)
}
@Test(arguments: ["null", "true", "42", "[]", "{}", "\"Light\"", "\"darkAqua\"", "\"\""]) @MainActor
func invalidGeneratedVariantsReportTheThemePath(_ value: String) throws {
let directory = try lightConfigurationDirectory()
defer { try? FileManager.default.removeItem(at: directory) }
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
let theme = directory.appendingPathComponent("theme.json")
try Data("{\"variant\":\(value),\"colors\":{}}".utf8).write(to: theme)
do {
_ = try ColorScheme.load(from: directory)
Issue.record("Expected invalid generated variant to be rejected")
} catch {
#expect(error.localizedDescription.contains(theme.path))
#expect(error.localizedDescription.contains("variant"))
}
}
@Test @MainActor
func variantOnlyReloadNotifiesConsumersAndChangesRenderedAppearance() throws {
let original = ThemePalette.loaded
let directory = try lightConfigurationDirectory()
defer {
ThemePalette.loaded = original
try? FileManager.default.removeItem(at: directory)
}
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
let theme = directory.appendingPathComponent("theme.json")
try Data(swampLightTheme.replacingOccurrences(of: "\"light\"", with: "\"dark\"").utf8).write(to: theme)
ThemePalette.reload(from: directory)
let changes = LightPaletteChanges()
let observer = NotificationCenter.default.addObserver(forName: ThemePalette.didChangeNotification,
object: nil, queue: .main) { _ in
MainActor.assumeIsolated {
changes.appearances.append(ThemePalette.appearance.name)
}
}
defer { NotificationCenter.default.removeObserver(observer) }
let before = try renderedBackground(over: .black)
try Data(swampLightTheme.utf8).write(to: theme)
#expect(ThemePalette.reload(from: directory))
#expect(changes.appearances == [.aqua])
#expect(!ThemePalette.reload(from: directory))
#expect(changes.appearances.count == 1)
let after = try renderedBackground(over: .black)
let oldSurface = try #require(before.colorAt(x: 375, y: 240))
let newSurface = try #require(after.colorAt(x: 375, y: 240))
#expect(testLuminance(newSurface) > testLuminance(oldSurface))
}
@Test @MainActor
func lightSecondaryTextRemainsReadableAcrossRenderedGlowsWithoutChangingSourceColors() throws {
let original = ThemePalette.loaded
let directory = try lightConfigurationDirectory()
defer {
ThemePalette.loaded = original
try? FileManager.default.removeItem(at: directory)
}
try Data("theme = 'tint'\n".utf8).write(to: directory.appendingPathComponent("config.toml"))
try Data(swampLightTheme.utf8).write(to: directory.appendingPathComponent("theme.json"))
let source = try ColorScheme.load(from: directory)
ThemePalette.reload(from: directory)
#expect(ThemePalette.text == source.text)
#expect(ThemePalette.loaded.scheme.secondaryText == source.secondaryText)
#expect(testLuminance(ThemePalette.secondaryText) < testLuminance(source.secondaryText))
for backing in [NSColor.black, .white] {
let bitmap = try renderedBackground(over: backing)
for y in stride(from: 0, to: bitmap.pixelsHigh, by: 12) {
for x in stride(from: 0, to: bitmap.pixelsWide, by: 12) {
let surface = try #require(bitmap.colorAt(x: x, y: y))
let foreground = testLuminance(ThemePalette.secondaryText)
#expect((testLuminance(surface) + 0.05) / (foreground + 0.05) >= 4.5)
}
}
}
// An already-readable manual secondary color is not needlessly restyled.
try Data("theme = 'tint'\ncolors.secondaryText = '#302820'\n".utf8)
.write(to: directory.appendingPathComponent("config.toml"))
ThemePalette.reload(from: directory)
#expect(ThemePalette.secondaryText == ThemePalette.loaded.scheme.secondaryText)
}
@Test @MainActor
func darkBackgroundRenderingAndSemanticColorsRemainUnchanged() throws {
let original = ThemePalette.loaded
defer { ThemePalette.loaded = original }
for scheme in [ColorScheme.sunset, customDarkScheme()] {
ThemePalette.loaded = ThemePalette.State(scheme: scheme)
#expect(ThemePalette.secondaryText == scheme.secondaryText)
#expect(ThemePalette.text == scheme.text)
#expect(ThemePalette.appearance.name == .darkAqua)
for backing in [NSColor.black, .white] {
let actual = try renderedBackground(over: backing)
let legacy = try renderedBackground(over: backing, legacy: scheme)
for y in stride(from: 0, to: actual.pixelsHigh, by: 12) {
for x in stride(from: 0, to: actual.pixelsWide, by: 12) {
#expect(actual.colorAt(x: x, y: y) == legacy.colorAt(x: x, y: y))
}
}
}
}
}
@MainActor private final class LightPaletteChanges {
var appearances: [NSAppearance.Name] = []
}
private func lightConfigurationDirectory() throws -> URL {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory
}
@MainActor private func customDarkScheme() -> ColorScheme {
var scheme = ColorScheme.sunset
scheme.background = NSColor(srgbRed: 0.04, green: 0.08, blue: 0.12, alpha: 1)
scheme.accent = .cyan
scheme.glow = .magenta
return scheme
}
private func testLuminance(_ color: NSColor) -> CGFloat {
let rgb = color.usingColorSpace(.sRGB)!
func linear(_ value: CGFloat) -> CGFloat {
value <= 0.04045 ? value / 12.92 : pow((value + 0.055) / 1.055, 2.4)
}
return 0.2126 * linear(rgb.redComponent) + 0.7152 * linear(rgb.greenComponent) + 0.0722 * linear(rgb.blueComponent)
}
@MainActor private func renderedBackground(over backing: NSColor, legacy scheme: ColorScheme? = nil) throws -> NSBitmapImageRep {
_ = NSApplication.shared
let bounds = NSRect(x: 0, y: 0, width: 750, height: 480)
let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 750, pixelsHigh: 480,
bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0))
let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap))
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
NSGraphicsContext.current = context
backing.setFill()
bounds.fill()
if let scheme {
scheme.background.withAlphaComponent(0.55).setFill()
bounds.fill()
let amber = try #require(NSGradient(colors: [scheme.accent.withAlphaComponent(0.18),
scheme.accent.withAlphaComponent(0.065), scheme.accent.withAlphaComponent(0)]))
let ember = try #require(NSGradient(colors: [scheme.glow.withAlphaComponent(0.26),
scheme.glow.withAlphaComponent(0.10), scheme.glow.withAlphaComponent(0)]))
let upper = NSPoint(x: bounds.width * 0.58, y: bounds.height * 0.94)
ember.draw(fromCenter: upper, radius: 0, toCenter: upper, radius: bounds.width * 0.40, options: [])
let lower = NSPoint(x: bounds.width * 0.88, y: bounds.height * 0.10)
amber.draw(fromCenter: lower, radius: 0, toCenter: lower, radius: bounds.width * 0.55, options: [])
let left = NSPoint(x: bounds.width * 0.08, y: bounds.height * 0.65)
context.cgContext.setAlpha(0.45)
amber.draw(fromCenter: left, radius: 0, toCenter: left, radius: bounds.width * 0.32, options: [])
} else {
LauncherBackground(frame: bounds).draw(bounds)
}
return bitmap
}
@@ -0,0 +1,137 @@
import AppKit
import ConjureKit
import Testing
@testable import Conjure
@Test @MainActor
func existingWindowsAndNotesFollowPaletteAppearanceWithoutResettingState() throws {
_ = NSApplication.shared
let originalPalette = ThemePalette.loaded
let originalAppearance = NSApp.appearance
defer {
NSApp.appearance = originalAppearance
ThemePalette.loaded = originalPalette
NotificationCenter.default.post(name: ThemePalette.didChangeNotification, object: nil)
}
let suite = "Conjure.LightAppearanceViews.\(UUID().uuidString)"
let defaults = try #require(UserDefaults(suiteName: suite))
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(suite)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer {
defaults.removePersistentDomain(forName: suite)
try? FileManager.default.removeItem(at: directory)
}
try Data("theme = \"tint\"".utf8).write(to: directory.appendingPathComponent("config.toml"))
func writePalette(light: Bool) throws {
try Data("""
{"variant":"\(light ? "light" : "dark")","colors":{
"background":"\(light ? "#ffffff" : "#201714")",
"text":"\(light ? "#112233" : "#ffe3b5")",
"secondaryText":"\(light ? "#667788" : "#d9c7a6")"
}}
""".utf8).write(to: directory.appendingPathComponent("theme.json"))
}
func descendants(_ view: NSView) -> [NSView] { [view] + view.subviews.flatMap(descendants) }
func appearanceName(_ appearance: NSAppearance) -> NSAppearance.Name? {
appearance.bestMatch(from: [.aqua, .darkAqua])
}
try writePalette(light: false)
ThemePalette.reload(from: directory)
NSApp.appearance = NSAppearance(named: .aqua)
let launcher = LauncherWindowController(
history: LaunchHistory(defaults: defaults, now: { Date(timeIntervalSinceReferenceDate: 12_000) }),
defaults: defaults, notesDirectory: directory.appendingPathComponent("notes"))
let window = try #require(launcher.window)
let content = try #require(window.contentView)
defer { window.orderOut(nil) }
#expect(appearanceName(window.effectiveAppearance) == .darkAqua)
let search = try #require(descendants(content).compactMap { $0 as? NSSearchField }.first)
search.stringValue = "notes"
launcher.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: search))
_ = launcher.control(search, textView: NSTextView(), doCommandBy: #selector(NSResponder.insertNewline(_:)))
let editor = try #require(descendants(content).compactMap { $0 as? NotesTextView }.first)
let document = NSMutableAttributedString(string: "let answer = 42\nplain")
let code = NSRange(location: 0, length: 16)
document.addAttribute(NoteAttributes.block, value: "code", range: code)
document.addAttribute(NoteAttributes.language, value: "swift", range: code)
editor.setDocumentContents(document)
editor.beginInsertionSession()
editor.insertText("!", replacementRange: NSRange(location: document.length, length: 0))
editor.endInsertionSession()
let selection = NSRange(location: 1, length: 2)
editor.setSelectedRange(selection)
let text = editor.string
let undoGrouping = editor.undoManager?.groupingLevel
let frame = window.frame
let style = window.styleMask
let settings = SettingsWindowController(shortcuts: GlobalShortcuts(defaults: defaults) { _ in })
let settingsWindow = try #require(settings.window)
defer { settingsWindow.close() }
let settingsContent = try #require(settingsWindow.contentView)
let destination = try #require(descendants(settingsContent).compactMap { $0 as? NSPopUpButton }.first)
destination.selectItem(at: 2)
let credential = try #require(descendants(settingsContent).compactMap { $0 as? NSSecureTextField }.first)
credential.stringValue = "unsaved-value"
let anchor = try #require(descendants(content).compactMap { $0 as? NSButton }.first {
$0.title == "Actions" && $0.target is NotesController
})
let notes = try #require(anchor.target as? NotesController)
let popup = ApplicationActionsController()
popup.showLanguages([("en", "English"), ("ja", "Japanese")], selected: "en", source: true, relativeTo: anchor)
defer { popup.close(animated: false) }
let popupSearch = try #require(descendants(popup.view).compactMap { $0 as? NSSearchField }.first)
popupSearch.stringValue = "Eng"
popup.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: popupSearch))
let table = try #require(descendants(popup.view).compactMap { $0 as? NSTableView }.first)
let rows = table.numberOfRows
let selectedRows = table.selectedRowIndexes
let material = try #require(popup.view.subviews.compactMap { $0 as? NSVisualEffectView }.first)
#expect(appearanceName(editor.effectiveAppearance) == .darkAqua)
#expect(appearanceName(settingsWindow.effectiveAppearance) == .darkAqua)
#expect(appearanceName(material.effectiveAppearance) == .darkAqua)
// Deliberately oppose the light palette: app/system inheritance is not the contract.
NSApp.appearance = NSAppearance(named: .darkAqua)
for light in [true, false] {
try writePalette(light: light)
#expect(ThemePalette.reload(from: directory))
let expected: NSAppearance.Name = light ? .aqua : .darkAqua
for appearance in [window.effectiveAppearance, content.effectiveAppearance, editor.effectiveAppearance,
settingsWindow.effectiveAppearance, settingsContent.effectiveAppearance,
popup.view.effectiveAppearance, material.effectiveAppearance] {
#expect(appearanceName(appearance) == expected)
}
#expect(launcher.window === window)
#expect(window.frame == frame)
#expect(window.styleMask == style)
#expect(editor.string == text)
#expect(editor.selectedRange() == selection)
#expect(editor.undoManager?.groupingLevel == undoGrouping)
#expect(editor.undoManager?.canUndo == true)
#expect(editor.textStorage?.attribute(NoteAttributes.block, at: 0, effectiveRange: nil) as? String == "code")
#expect(editor.textStorage?.attribute(NoteAttributes.language, at: 0, effectiveRange: nil) as? String == "swift")
let keyword = try #require((editor.textStorage?.attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor)?.usingColorSpace(.sRGB))
let rgb: (CGFloat, CGFloat, CGFloat) = light ? (166, 38, 164) : (198, 120, 221)
#expect(abs(keyword.redComponent * 255 - rgb.0) < 1)
#expect(abs(keyword.greenComponent * 255 - rgb.1) < 1)
#expect(abs(keyword.blueComponent * 255 - rgb.2) < 1)
#expect(destination.indexOfSelectedItem == 2)
#expect(credential.stringValue == "unsaved-value")
#expect(popup.isShown)
#expect(popupSearch.stringValue == "Eng")
#expect(table.numberOfRows == rows)
#expect(table.selectedRowIndexes == selectedRows)
#expect(material.layer?.borderColor == ThemePalette.secondaryText.withAlphaComponent(0.22).cgColor)
#expect(material.layer?.borderColor?.alpha == 0.22)
}
popup.close(animated: false)
editor.undoManager?.undo()
#expect(editor.string == document.string)
try writePalette(light: true)
#expect(ThemePalette.reload(from: directory))
notes.goBack()
#expect(window.styleMask == [.borderless])
#expect(appearanceName(window.effectiveAppearance) == .aqua)
#expect(appearanceName(content.effectiveAppearance) == .aqua)
#expect(search.stringValue == "notes")
}
@@ -28,7 +28,7 @@ func livePaletteReloadsGeneratedColorsKeepsOverridesAndRetainsLastValidPalette()
}
defer { NotificationCenter.default.removeObserver(observer) }
try Data("theme = 'tint'\ncolors.accent = '#ffffff'\n".utf8).write(to: config)
try Data(##"{"colors":{"background":"#123456","accent":"#ff0000","text":"#000000"}}"##.utf8)
try Data(##"{"variant":"dark","colors":{"background":"#123456","accent":"#ff0000","text":"#000000"}}"##.utf8)
.write(to: theme)
#expect(ThemePalette.reload(from: directory))
let firstBackground = ThemePalette.background
@@ -48,7 +48,7 @@ func livePaletteRepaintsNotesWithoutChangingContentSelectionOrUndo() throws {
#expect(editor.textStorage?.attribute(.foregroundColor, at: inline.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.accent)
#expect(editor.textStorage?.attribute(.backgroundColor, at: inline.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill)
#expect(editor.textStorage?.attribute(.backgroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor == ThemePalette.selectionFill)
#expect(editor.textStorage?.attribute(.foregroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor == codeColor)
#expect(editor.textStorage?.attribute(.foregroundColor, at: code.location + 1, effectiveRange: nil) as? NSColor != codeColor)
#expect(editor.textStorage?.attribute(.font, at: 1, effectiveRange: nil) as? NSFont == boldFont)
#expect(editor.textStorage?.attribute(NoteAttributes.inlineCode, at: inline.location + 1, effectiveRange: nil) as? Bool == true)
#expect(editor.textStorage?.attribute(NoteAttributes.language, at: code.location + 1, effectiveRange: nil) as? String == "swift")