Repository navigation
Aurean appearance system: φ-based theme with selectable palette variants #5050
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
72289f7
79e1c59
551e9cb
62aeaee
011bf05
bc2a1d3
c6e3bb3
06baa10
350fe8c
7fc7f96
f96e62b
ad41bed
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| // swift-tools-version: 6.0 | ||
| import PackageDescription | ||
|
|
||
| let package = Package( | ||
| name: "CmuxAppearance", | ||
| platforms: [.macOS(.v14)], | ||
| products: [ | ||
| .library(name: "CmuxAppearance", targets: ["CmuxAppearance"]) | ||
| ], | ||
| targets: [ | ||
| .target(name: "CmuxAppearance"), | ||
| .testTarget( | ||
| name: "CmuxAppearanceTests", | ||
| dependencies: ["CmuxAppearance"] | ||
| ) | ||
| ] | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import Foundation | ||
|
|
||
| /// The semantic color surface every Aurean palette must provide. | ||
| /// | ||
| /// Views consume colors through these eight semantic roles, never through raw hex, | ||
| /// so swapping the active palette (cool ⇄ dune ⇄ warm ⇄ obsidian) re-skins the whole | ||
| /// app without touching call sites. The four **signal** roles | ||
| /// (``accent``/``ok``/``warn``/``crit``) carry meaning that is stable across palettes — | ||
| /// only the negative-space temperature and the ``text`` tone shift — which preserves | ||
| /// the user's muscle memory. | ||
| /// | ||
| /// Conform a value type to this protocol; ``AureanPalette`` is the canonical | ||
| /// implementation. Keep it `Sendable` so palettes can cross actor boundaries. | ||
| public protocol AppearancePalette: Sendable { | ||
| /// Primary pane background (the design's `surface.primary` / liminal). | ||
| var surfacePrimary: AureanColor { get } | ||
| /// Raised surfaces — sidebars, headers, tab bars (`surface.off` / liminal-off). | ||
| var surfaceOff: AureanColor { get } | ||
| /// Deepest chrome — titlebar and status bar (`surface.abyssal`). | ||
| var surfaceAbyssal: AureanColor { get } | ||
|
|
||
| /// Primary typography and grid lines (the design's "sand"). | ||
| var text: AureanColor { get } | ||
|
|
||
| /// Signal: active selection, highlight, focus ring, search match (liminal-blue). | ||
| var accent: AureanColor { get } | ||
| /// Signal: running agent, input prompt, success, spend telemetry (flow-green). | ||
| var ok: AureanColor { get } | ||
| /// Signal: needs-input and dirty state (gold). Fixed across palettes. | ||
| var warn: AureanColor { get } | ||
| /// Signal: failure, deletions, forbidden actions (rust). Fixed across palettes. | ||
| var crit: AureanColor { get } | ||
| } | ||
|
|
||
| extension AppearancePalette { | ||
| /// The ``text`` color projected onto a φ-opacity stop. | ||
| /// | ||
| /// - Parameter stop: The opacity ladder rung (border, faint, dust …). | ||
| /// - Returns: Sand at the requested opacity — the standard way to draw rules, | ||
| /// secondary labels, and washes without introducing new hues. | ||
| public func text(_ stop: AureanOpacity) -> AureanColor { | ||
| text.opacity(stop.value) | ||
| } | ||
|
|
||
| /// The ``accent`` color projected onto a φ-opacity stop (e.g. selection washes). | ||
| /// - Parameter stop: The opacity ladder rung. | ||
| /// - Returns: Accent at the requested opacity. | ||
| public func accent(_ stop: AureanOpacity) -> AureanColor { | ||
| accent.opacity(stop.value) | ||
| } | ||
|
|
||
| /// The ``ok`` color projected onto a φ-opacity stop (e.g. agent-pane phosphor wash). | ||
| /// - Parameter stop: The opacity ladder rung. | ||
| /// - Returns: Flow-green at the requested opacity. | ||
| public func ok(_ stop: AureanOpacity) -> AureanColor { | ||
| ok.opacity(stop.value) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| import SwiftUI | ||
|
|
||
| /// A resolution-independent, testable color value used throughout the Aurean theme layer. | ||
| /// | ||
| /// ``AureanColor`` stores straight (non-premultiplied) sRGB components in the `0...1` | ||
| /// range plus an alpha channel. It deliberately avoids wrapping `SwiftUI.Color` or | ||
| /// `NSColor` as its storage so that the whole theme layer can be unit-tested without | ||
| /// launching AppKit or a rendering context — tests assert on ``red``/``green``/``blue``/ | ||
| /// ``alpha`` directly. | ||
| /// | ||
| /// Convert to platform colors only at the view boundary via ``color`` or ``nsColor``. | ||
| /// | ||
| /// ```swift | ||
| /// let sand = AureanColor(hex: "#C4C7CC") | ||
| /// Text("hello").foregroundStyle(sand.color) | ||
| /// ``` | ||
| public struct AureanColor: Sendable, Hashable, Codable { | ||
| /// The red component, in the sRGB `0...1` range. | ||
| public let red: Double | ||
| /// The green component, in the sRGB `0...1` range. | ||
| public let green: Double | ||
| /// The blue component, in the sRGB `0...1` range. | ||
| public let blue: Double | ||
| /// The opacity, in the `0...1` range, where `1` is fully opaque. | ||
| public let alpha: Double | ||
|
|
||
| /// Creates a color from straight sRGB components. | ||
| /// | ||
| /// - Parameters: | ||
| /// - red: Red component, clamped to `0...1`. | ||
| /// - green: Green component, clamped to `0...1`. | ||
| /// - blue: Blue component, clamped to `0...1`. | ||
| /// - alpha: Opacity, clamped to `0...1`. Defaults to `1` (opaque). | ||
| public init(red: Double, green: Double, blue: Double, alpha: Double = 1) { | ||
| self.red = red.clamped01 | ||
| self.green = green.clamped01 | ||
| self.blue = blue.clamped01 | ||
| self.alpha = alpha.clamped01 | ||
| } | ||
|
|
||
| /// Creates a color from a `#RRGGBB` or `#RRGGBBAA` hex string. | ||
| /// | ||
| /// The leading `#` is optional and parsing is case-insensitive. An unparseable | ||
| /// string yields opaque black, so this initializer never fails — the Aurean tokens | ||
| /// are compile-time constants validated by tests, not user input. | ||
| /// | ||
| /// - Parameter hex: A 6- or 8-digit hex string, e.g. `"#161819"` or `"161819FF"`. | ||
| public init(hex: String) { | ||
| var s = Substring(hex) | ||
| if s.first == "#" { s = s.dropFirst() } | ||
| let digits = String(s) | ||
| func byte(_ start: Int) -> Double { | ||
| let lo = digits.index(digits.startIndex, offsetBy: start) | ||
| let hi = digits.index(lo, offsetBy: 2) | ||
| return Double(UInt8(digits[lo..<hi], radix: 16) ?? 0) / 255.0 | ||
| } | ||
| guard digits.count == 6 || digits.count == 8, | ||
| digits.allSatisfy({ $0.isHexDigit }) else { | ||
| self.init(red: 0, green: 0, blue: 0, alpha: 1) | ||
| return | ||
| } | ||
| self.init( | ||
| red: byte(0), | ||
| green: byte(2), | ||
| blue: byte(4), | ||
| alpha: digits.count == 8 ? byte(6) : 1 | ||
| ) | ||
| } | ||
|
|
||
| /// Returns a copy of this color with its alpha replaced by `opacity`. | ||
| /// | ||
| /// Used to project a solid token onto one of the Aurean φ-opacity stops | ||
| /// (see ``AureanOpacity``) — depth in this theme is opacity, never shadow. | ||
| /// | ||
| /// - Parameter opacity: The new alpha, clamped to `0...1`. | ||
| /// - Returns: A color identical in hue but at the requested opacity. | ||
| public func opacity(_ opacity: Double) -> AureanColor { | ||
| AureanColor(red: red, green: green, blue: blue, alpha: opacity) | ||
| } | ||
|
|
||
| /// The SwiftUI representation, in the sRGB color space. | ||
| public var color: Color { | ||
| Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) | ||
| } | ||
|
|
||
| /// The AppKit representation, in the sRGB color space. | ||
| public var nsColor: NSColor { | ||
| NSColor(srgbRed: red, green: green, blue: blue, alpha: alpha) | ||
| } | ||
| } | ||
|
|
||
| private extension Double { | ||
| var clamped01: Double { Swift.min(1, Swift.max(0, self)) } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| import CoreGraphics | ||
| import Foundation | ||
|
|
||
| /// The dimensionless constants of the Aurean Protocol: the golden ratio, the Fibonacci | ||
| /// spacing ladder, the φ type scale, motion timings, and the fixed chrome geometry. | ||
| /// | ||
| /// These are pure declarations transcribed from the design's `tokens.css`. They carry no | ||
| /// behavior — only the numbers every surface is measured against. Multiply spacing by a | ||
| /// runtime `density` (0.75–1.4) at the call site; the ladder here is at density `1.0`. | ||
| public struct AureanMetrics: Sendable { | ||
| /// The golden ratio, φ. | ||
| public static let phi: CGFloat = 1.618033988749 | ||
| /// `1/φ` — the major share of a golden split (`0.618`). | ||
| public static let phiInverse: CGFloat = 0.618033988749 | ||
| /// `1/φ²` (`0.382`) — the minor share of a golden split. | ||
| public static let phiInverse2: CGFloat = 0.381966011250 | ||
| /// `1/φ³` (`0.236`). | ||
| public static let phiInverse3: CGFloat = 0.236067977499 | ||
| /// `1/φ⁴` (`0.145`). | ||
| public static let phiInverse4: CGFloat = 0.145898033750 | ||
|
|
||
| /// The Fibonacci spacing ladder in points, at density `1.0`. | ||
| public struct Spacing: Sendable { | ||
| public static let s1: CGFloat = 1 | ||
| public static let s2: CGFloat = 2 | ||
| public static let s3: CGFloat = 3 | ||
| public static let s5: CGFloat = 5 | ||
| public static let s8: CGFloat = 8 | ||
| public static let s13: CGFloat = 13 | ||
| public static let s21: CGFloat = 21 | ||
| public static let s34: CGFloat = 34 | ||
| public static let s55: CGFloat = 55 | ||
| public static let s89: CGFloat = 89 | ||
| public static let s144: CGFloat = 144 | ||
| public static let s233: CGFloat = 233 | ||
| } | ||
|
Comment on lines
+23
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Add DocC comments to the The 🤖 Prompt for AI Agents |
||
|
|
||
| /// The φ-anchored type scale (anchor 16px, ratio φ) and its line metrics. | ||
| public struct TypeScale: Sendable { | ||
| /// The anchor size all others derive from. | ||
| public static let anchor: CGFloat = 16 | ||
| /// `anchor / φ` ≈ 10 — uppercase technical labels. | ||
| public static let micro: CGFloat = 10 | ||
| /// The body size (`anchor`). | ||
| public static let base: CGFloat = 16 | ||
| /// `anchor · φ` ≈ 26. | ||
| public static let h3: CGFloat = 26 | ||
| /// `h3 · φ` ≈ 42. | ||
| public static let h2: CGFloat = 42 | ||
| /// `h2 · φ` ≈ 68. | ||
| public static let h1: CGFloat = 68 | ||
| /// Base line-height multiple (φ). | ||
| public static let lineHeight: CGFloat = 1.618 | ||
| /// Default letter tracking, in em. | ||
| public static let tracking: CGFloat = 0.01 | ||
| /// Wide tracking for uppercase labels, in em. | ||
| public static let trackingWide: CGFloat = 0.14 | ||
| /// The primary mono family, with fallbacks resolved by the platform. | ||
| public static let monoFamily = "JetBrains Mono" | ||
| /// Ordered fallbacks when the primary face is unavailable. | ||
| public static let monoFallbacks = ["Fira Code", "Berkeley Mono", "SF Mono", "Menlo"] | ||
| } | ||
|
|
||
| /// Fibonacci-derived motion durations (seconds) and φ easing control points. | ||
| public struct Motion: Sendable { | ||
| /// Instant feedback (`160ms`). | ||
| public static let feedback: Double = 0.160 | ||
| /// Micro interactions — selection, focus ring (`260ms`). | ||
| public static let micro: Double = 0.260 | ||
| /// Panel and split transitions (`420ms`). | ||
| public static let panel: Double = 0.420 | ||
| /// Context and workspace switches (`680ms`). | ||
| public static let context: Double = 0.680 | ||
| /// The status-dot pulse cycle (`2618ms`, looped). | ||
| public static let pulse: Double = 2.618 | ||
| /// Control points for the φ ease curve: `cubic-bezier(0.382, 0, 0.236, 1)`. | ||
| public static let ease: (CGFloat, CGFloat, CGFloat, CGFloat) = (0.382, 0, 0.236, 1) | ||
| /// Control points for the φ ease-out curve: `cubic-bezier(0.145, 0.618, 0.236, 1)`. | ||
| public static let easeOut: (CGFloat, CGFloat, CGFloat, CGFloat) = (0.145, 0.618, 0.236, 1) | ||
| } | ||
|
|
||
| /// Fixed chrome geometry in points. | ||
| public struct Geometry: Sendable { | ||
| /// Titlebar height. | ||
| public static let titlebarHeight: CGFloat = 38 | ||
| /// Status bar height. | ||
| public static let statusBarHeight: CGFloat = 27 | ||
| /// Pane header height. | ||
| public static let paneHeaderHeight: CGFloat = 28 | ||
| /// Workspace rail width in the dense Cockpit direction. | ||
| public static let railCockpitWidth: CGFloat = 208 | ||
| /// Workspace rail width in the calm Atelier direction (glyph-only). | ||
| public static let railAtelierWidth: CGFloat = 64 | ||
| /// Right sidebar width in Cockpit. | ||
| public static let rightSidebarCockpitWidth: CGFloat = 296 | ||
| /// Right sidebar width in Atelier. | ||
| public static let rightSidebarAtelierWidth: CGFloat = 264 | ||
| /// Split gutter thickness. | ||
| public static let gutter: CGFloat = 1 | ||
| /// Drag-grab tick length on the gutter. | ||
| public static let gutterGrabTick: CGFloat = 34 | ||
| /// Inner focus-ring thickness (never a glow or shadow). | ||
| public static let focusRing: CGFloat = 1 | ||
| } | ||
|
|
||
| /// The golden pane split: there is no 50/50, and equalize (`⌥⌘=`) returns to this. | ||
| public struct Split: Sendable { | ||
| /// The major pane's fraction (`0.618`). | ||
| public static let major: CGFloat = AureanMetrics.phiInverse | ||
| /// The minor pane's fraction (`0.382`). | ||
| public static let minor: CGFloat = AureanMetrics.phiInverse2 | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| import Foundation | ||
|
|
||
| /// The φ-derived opacity ladder that encodes depth in the Aurean theme. | ||
| /// | ||
| /// Depth is expressed **only** through opacity — never drop shadows or bevels (the | ||
| /// single exception is the window's own near-black cast shadow, which lives outside | ||
| /// this theme). Each stop is `1/φⁿ`, matching the design tokens. | ||
| /// | ||
| /// ```swift | ||
| /// let border = palette.text.opacity(AureanOpacity.border.value) // sand @ 0.236 | ||
| /// ``` | ||
| public enum AureanOpacity: Double, Sendable, CaseIterable { | ||
| /// Fully opaque (`1.0`) — solid fills. | ||
| case solid = 1.0 | ||
| /// `1/φ` (`0.618`) — organic primary detail. | ||
| case organic = 0.618 | ||
| /// `1/φ²` (`0.382`) — secondary content. | ||
| case secondary = 0.382 | ||
| /// `1/φ³` (`0.236`) — standard rules and borders. | ||
| case border = 0.236 | ||
| /// `1/φ⁴` (`0.145`) — faint trails and wash gradients. | ||
| case faint = 0.145 | ||
| /// `0.090` — telemetry "dust" and ghost rules. | ||
| case dust = 0.090 | ||
| /// `0.045` — the faintest pane-header wash. | ||
| case whisper = 0.045 | ||
|
|
||
| /// The raw opacity value in the `0...1` range. | ||
| public var value: Double { rawValue } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import Foundation | ||
|
|
||
| /// The canonical ``AppearancePalette`` implementation, carrying the exact token values | ||
| /// for each ``AureanPaletteVariant``. | ||
| /// | ||
| /// Hex values are transcribed verbatim from the design's `tokens.css` (the source of | ||
| /// truth). The `warn` (gold `#E5C07B`) and `crit` (rust `#FF8A66`) signals are identical | ||
| /// across every variant by design; only negative space, sand, and the cool signals shift. | ||
| /// | ||
| /// ```swift | ||
| /// let palette = AureanPalette(variant: .cool) // cmux default | ||
| /// view.background(palette.surfacePrimary.color) | ||
| /// ``` | ||
| public struct AureanPalette: AppearancePalette, Sendable, Hashable { | ||
| public let surfacePrimary: AureanColor | ||
| public let surfaceOff: AureanColor | ||
| public let surfaceAbyssal: AureanColor | ||
| public let text: AureanColor | ||
| public let accent: AureanColor | ||
| public let ok: AureanColor | ||
| public let warn: AureanColor | ||
| public let crit: AureanColor | ||
|
Comment on lines
+15
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial | ⚡ Quick win Public stored properties are missing DocC comments.
🤖 Prompt for AI Agents |
||
|
|
||
| /// The temperature this palette was built from. | ||
| public let variant: AureanPaletteVariant | ||
|
|
||
| /// Builds the concrete palette for a temperature. | ||
| /// - Parameter variant: The Aurean temperature; defaults to ``AureanPaletteVariant/cool``. | ||
| public init(variant: AureanPaletteVariant = .cool) { | ||
| self.variant = variant | ||
| // Signals shared across all palettes (muscle-memory invariants). | ||
| self.warn = AureanColor(hex: "#E5C07B") // gold | ||
| self.crit = AureanColor(hex: "#FF8A66") // rust | ||
| switch variant { | ||
| case .cool: | ||
| surfacePrimary = AureanColor(hex: "#161819") | ||
| surfaceOff = AureanColor(hex: "#121314") | ||
| surfaceAbyssal = AureanColor(hex: "#0E1011") | ||
| text = AureanColor(hex: "#C4C7CC") | ||
| accent = AureanColor(hex: "#B8D8E8") | ||
| ok = AureanColor(hex: "#B6D4B0") | ||
| case .dune: | ||
| surfacePrimary = AureanColor(hex: "#1A1817") | ||
| surfaceOff = AureanColor(hex: "#141312") | ||
| surfaceAbyssal = AureanColor(hex: "#100E0D") | ||
| text = AureanColor(hex: "#D2C0B0") | ||
| accent = AureanColor(hex: "#AEE2EA") | ||
| ok = AureanColor(hex: "#C5E1A5") | ||
| case .warm: | ||
| surfacePrimary = AureanColor(hex: "#1E1A15") | ||
| surfaceOff = AureanColor(hex: "#18140F") | ||
| surfaceAbyssal = AureanColor(hex: "#140F0A") | ||
| text = AureanColor(hex: "#E0CAB2") | ||
| accent = AureanColor(hex: "#B8DDDC") | ||
| ok = AureanColor(hex: "#D0DFA5") | ||
| case .obsidian: | ||
| surfacePrimary = AureanColor(hex: "#15171A") | ||
| surfaceOff = AureanColor(hex: "#101214") | ||
| surfaceAbyssal = AureanColor(hex: "#0B0D0F") | ||
| text = AureanColor(hex: "#C9D0D4") | ||
| accent = AureanColor(hex: "#A3C9D6") | ||
| ok = AureanColor(hex: "#B6D8B8") | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing required per-locale changelog localization coverage.
Line 8 adds new user-facing changelog copy, but there’s no corresponding evidence in this PR layer that docs-localized entries were added for every locale supported by
web/i18n/routing.ts. This should block merge under the repo i18n policy for changelog updates.As per coding guidelines: “
CHANGELOG.md: Apply.github/review-bot-rules/full-internationalization.mdduring review. Verify changelog copy rendered on the docs site has matching localized message coverage for every locale listed inweb/i18n/routing.ts.”🧰 Tools
🪛 markdownlint-cli2 (0.22.1)
[warning] 7-7: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below
(MD022, blanks-around-headings)
🤖 Prompt for AI Agents