Four rulings close Redesign Contradiction 3452893f (2026-08-06): the in-app escape hatch is ratified in full, reversing 2026-07-29's palette-only rule — the combo's Other… opens the system Colors panel, a pick landing on a palette color stores the name, anything else the hex. Free-picked colors change no contrast story: they land on the same runtime ink computation hand-written hex always got (10 amended to say so; no warning surface is owed). Anchor ownership: the card sidebar's background story is the combo alone — the well grid's background half stays with the other anchors (StyleEditorView gains showsBackground beside showsSymbols; the popover's symbol half already went to its inline SymbolPicker). Quick-style recents stay palette-vocabulary — a panel pick never enters them. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
213 lines
11 KiB
Swift
213 lines
11 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// The colour vocabulary the `background` and `iconColor` fields are written in
|
|
/// (03-board-ui.md § Styling ▸ Capabilities): **a kebab-case palette name, or a `#RRGGBB[AA]`
|
|
/// hex**. This file is the single source for the name→hex mapping — the pathfinder's twelve icon
|
|
/// tints and twelve backgrounds, carried over verbatim as the starting point ("The pathfinder's
|
|
/// palettes (12 icon tints, 12 backgrounds) carry over").
|
|
///
|
|
/// ### Lenient, never an error
|
|
///
|
|
/// A stored value is *cosmetic*, so an unrecognized one is not a load failure, not a warning, and
|
|
/// not a placeholder colour: resolution simply yields `nil` and the call site falls back to its
|
|
/// own default — no stripe for a card `background`, the standard secondary tint for an
|
|
/// `iconColor` (03 § Card face). The bytes on disk are left exactly as written until the author
|
|
/// changes them, which is what makes "custom hex is not pickable in-app but stays fully honored
|
|
/// from disk" (03 § Controls) true in both directions: curated in-app, unlimited on disk.
|
|
///
|
|
/// Names are matched **exactly** — kebab-case as the tables below spell them. `Background` is not
|
|
/// `background`, and a near-miss degrades like any other unknown value rather than guessing at
|
|
/// what the author meant.
|
|
struct PaletteColor: Identifiable, Sendable {
|
|
/// Kebab-case, exactly as written to frontmatter.
|
|
let name: String
|
|
let hex: String
|
|
var id: String { name }
|
|
}
|
|
|
|
enum Palette {
|
|
|
|
/// Icon-tint palette (`iconColor`).
|
|
static let foregrounds: [PaletteColor] = [
|
|
PaletteColor(name: "obsidian", hex: "#000000"),
|
|
PaletteColor(name: "aluminum", hex: "#9B9B9B"),
|
|
PaletteColor(name: "soapstone", hex: "#D5D5D5"),
|
|
PaletteColor(name: "chalk", hex: "#FFFFFF"),
|
|
PaletteColor(name: "carnation", hex: "#FF576C"),
|
|
PaletteColor(name: "rich-grapefruit", hex: "#FF864C"),
|
|
PaletteColor(name: "smokey-tangerine", hex: "#E5A334"),
|
|
PaletteColor(name: "fern", hex: "#50B23D"),
|
|
PaletteColor(name: "light-teal", hex: "#00B7B7"),
|
|
PaletteColor(name: "deep-sky-blue", hex: "#0084E5"),
|
|
PaletteColor(name: "pale-violet", hex: "#8C59C5"),
|
|
PaletteColor(name: "deep-cool-granite", hex: "#597199"),
|
|
]
|
|
|
|
/// Background palette (`background`) — the twelve wells the style editor will offer, "every
|
|
/// pair AA-verified at design time" (03-board-ui.md § Styling ▸ Controls).
|
|
static let backgrounds: [PaletteColor] = [
|
|
PaletteColor(name: "obsidian", hex: "#000000"),
|
|
PaletteColor(name: "shale", hex: "#5B5B5B"),
|
|
PaletteColor(name: "aluminum", hex: "#9B9B9B"),
|
|
PaletteColor(name: "chalk", hex: "#FFFFFF"),
|
|
PaletteColor(name: "light-cayenne", hex: "#B6071E"),
|
|
PaletteColor(name: "light-mocha", hex: "#B73C14"),
|
|
PaletteColor(name: "smokey-mocha", hex: "#674611"),
|
|
PaletteColor(name: "smokey-fern", hex: "#145312"),
|
|
PaletteColor(name: "dark-teal", hex: "#005152"),
|
|
PaletteColor(name: "smokey-ocean", hex: "#003168"),
|
|
PaletteColor(name: "smokey-rich-eggplant", hex: "#290659"),
|
|
PaletteColor(name: "intense-cool-shale", hex: "#1F2E45"),
|
|
]
|
|
}
|
|
|
|
// The pathfinder's panel round-trip helpers, ported below (`NSColor.paletteHexString`,
|
|
// `Palette.name(forHex:in:)`): the reusable colour-picker combo (`ColorComboView`, ColorCombo.swift)
|
|
// is the surface that finally needs them — a colour the *system picker* returns has to become a
|
|
// stored value the same way a palette pick already does: the palette NAME when the colour lands
|
|
// exactly on one of the twelve, the hex otherwise. Its swatch drawing, unchanged in spirit, is
|
|
// below: a menu can only render `Image`/`Text`, so both the quick-style row's dots and the combo's
|
|
// rows have to be pictures.
|
|
|
|
// MARK: - Menu swatches
|
|
|
|
/// A colour value drawn as a picture, for the one surface that cannot take a SwiftUI shape: **menu
|
|
/// items**. AppKit renders a menu row from its label's image and text, so the quick-style recents row
|
|
/// (03-board-ui.md § Styling ▸ Controls) needs an `NSImage` per dot where the editor's own wells are
|
|
/// ordinary views.
|
|
enum PaletteSwatch {
|
|
|
|
/// A filled dot for `value` (a palette name or `#RRGGBB[AA]` hex), hairline-bordered.
|
|
///
|
|
/// The border is not decoration: the background palette contains `chalk` (`#FFFFFF`), and an
|
|
/// unbordered white dot on a light menu is an invisible menu item — the same reason the editor's
|
|
/// wells are stroked (10-accessibility.md's contrast stance applied to the app's own chrome).
|
|
///
|
|
/// A value that resolves to nothing draws the border alone rather than a guessed colour, matching
|
|
/// every other lenient rendering here: there is no colour, so show none.
|
|
static func circleImage(for value: String, diameter: CGFloat = 14) -> NSImage {
|
|
let color = Palette.nsColor(for: value)
|
|
return NSImage(size: NSSize(width: diameter, height: diameter), flipped: false) { rect in
|
|
let inset = rect.insetBy(dx: 0.5, dy: 0.5)
|
|
let path = NSBezierPath(ovalIn: inset)
|
|
// Under a translucent colour the menu's own backdrop would show through unevenly across
|
|
// appearances; filling the standard control backdrop first makes the dot composite the
|
|
// same way in light and dark. An opaque colour covers it completely.
|
|
NSColor.textBackgroundColor.setFill()
|
|
path.fill()
|
|
color?.setFill()
|
|
path.fill()
|
|
NSColor.separatorColor.setStroke()
|
|
path.lineWidth = 1
|
|
path.stroke()
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// A wide rectangular swatch for `value` — `ColorComboView`'s own rows and its collapsed face,
|
|
/// which are wide and short rather than the quick-style row's small dots (hence a sibling
|
|
/// function rather than a parameter on `circleImage`: the two shapes are never interchangeable at
|
|
/// their call sites). `nil` draws the border alone, exactly `circleImage`'s "there is no colour,
|
|
/// so show none" — the collapsed face's **None** state and the dropdown's own **None** row both
|
|
/// call this with `nil` rather than a sentinel string.
|
|
static func rectImage(for value: String?, size: NSSize) -> NSImage {
|
|
let color = value.flatMap(Palette.nsColor(for:))
|
|
return NSImage(size: size, flipped: false) { rect in
|
|
let inset = rect.insetBy(dx: 0.5, dy: 0.5)
|
|
let path = NSBezierPath(rect: inset)
|
|
NSColor.textBackgroundColor.setFill()
|
|
path.fill()
|
|
color?.setFill()
|
|
path.fill()
|
|
NSColor.separatorColor.setStroke()
|
|
path.lineWidth = 1
|
|
path.stroke()
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
extension Palette {
|
|
|
|
/// Resolves a stored value — a palette name, or a hand-written `#RRGGBB`/`#RRGGBBAA` hex —
|
|
/// searching the icon tints first, then the backgrounds. **Both tables answer either field**:
|
|
/// the split is what each *picker* offers, not a namespace, so a hand-written
|
|
/// `background: carnation` resolves rather than reading as garbage.
|
|
///
|
|
/// `nil` means unrecognized, which is a rendering instruction ("use your default"), never an
|
|
/// error — see this file's leading comment.
|
|
static func nsColor(for value: String) -> NSColor? {
|
|
if value.hasPrefix("#") { return NSColor(paletteHex: value) }
|
|
guard let hex = (foregrounds + backgrounds).first(where: { $0.name == value })?.hex else { return nil }
|
|
return NSColor(paletteHex: hex)
|
|
}
|
|
|
|
/// SwiftUI variant of `nsColor(for:)` — what the views actually call.
|
|
static func color(named name: String) -> Color? {
|
|
nsColor(for: name).map { Color(nsColor: $0) }
|
|
}
|
|
|
|
/// The lenient read of a whole frontmatter field: a missing or malformed `background` /
|
|
/// `iconColor` resolves exactly like an unrecognized one — there is no colour, so use the
|
|
/// default.
|
|
///
|
|
/// Folding all three `FieldValue` shapes into one `nil` mirrors `ItemSymbol.name(_:fallback:)`
|
|
/// and keeps every call site free of the distinction, which no renderer has a use for.
|
|
static func color(for field: FieldValue<String>) -> Color? {
|
|
guard let value = field.value else { return nil }
|
|
return color(named: value)
|
|
}
|
|
|
|
/// The name of `palette`'s entry whose hex matches `hex`, case-insensitively — the pathfinder's
|
|
/// round trip, ported for `ColorComboView`'s panel handoff: a colour the system picker returns
|
|
/// comes back as `NSColor.paletteHexString`'s canonical `#RRGGBB[AA]`, and this is what turns
|
|
/// that back into "the user picked Light Cayenne" instead of leaving it as an anonymous hex.
|
|
/// `nil` when nothing in `palette` matches, which the caller reads as "store the hex instead."
|
|
static func name(forHex hex: String, in palette: [PaletteColor]) -> String? {
|
|
palette.first { $0.hex.caseInsensitiveCompare(hex) == .orderedSame }?.name
|
|
}
|
|
}
|
|
|
|
// MARK: - Hex ↔ colour
|
|
|
|
extension NSColor {
|
|
/// `#RRGGBB` or `#RRGGBBAA` → `NSColor` in **sRGB** — the colour space the hex digits name,
|
|
/// so a value hand-written from a screenshot or a design tool renders as the same colour the
|
|
/// author sampled. Returns `nil` for anything else: a missing `#`, a short or long digit run,
|
|
/// or a non-hex character.
|
|
convenience init?(paletteHex hex: String) {
|
|
var string = hex
|
|
if string.hasPrefix("#") { string.removeFirst() }
|
|
var alpha: CGFloat = 1
|
|
if string.count == 8 {
|
|
guard let alphaByte = UInt32(string.suffix(2), radix: 16) else { return nil }
|
|
alpha = CGFloat(alphaByte) / 255
|
|
string.removeLast(2)
|
|
}
|
|
guard string.count == 6, let value = UInt32(string, radix: 16) else { return nil }
|
|
self.init(
|
|
srgbRed: CGFloat((value >> 16) & 0xFF) / 255,
|
|
green: CGFloat((value >> 8) & 0xFF) / 255,
|
|
blue: CGFloat(value & 0xFF) / 255,
|
|
alpha: alpha
|
|
)
|
|
}
|
|
|
|
/// The reverse of `init?(paletteHex:)`: `#RRGGBB`, or `#RRGGBBAA` when the colour is
|
|
/// translucent — full opacity collapses to six digits rather than a trailing `FF`, so a colour
|
|
/// that round-trips through the panel without the user touching the opacity slider is written
|
|
/// exactly as a curated palette entry would be. `nil` only for a colour space **sRGB** cannot
|
|
/// convert into, which no picker swatch or palette entry here ever is.
|
|
var paletteHexString: String? {
|
|
guard let srgb = usingColorSpace(.sRGB) else { return nil }
|
|
let red = Int((srgb.redComponent * 255).rounded())
|
|
let green = Int((srgb.greenComponent * 255).rounded())
|
|
let blue = Int((srgb.blueComponent * 255).rounded())
|
|
let alpha = Int((srgb.alphaComponent * 255).rounded())
|
|
return alpha >= 255
|
|
? String(format: "#%02X%02X%02X", red, green, blue)
|
|
: String(format: "#%02X%02X%02X%02X", red, green, blue, alpha)
|
|
}
|
|
}
|