The colour combo was a wide two-zone field with a second door onto the Colors panel; the symbol picker was a small square button with one. Both now subclass one `ComboFieldControl`, so they are the same width, height, radius and trigger by construction: click the face for the standalone picker, click the chevron for the quick list. The symbol face opens a new floating browser over the OS's own category, ordering and keyword plists out of CoreGlyphs.bundle — searchable, categorised, trademark-restricted glyphs withheld. The palette grows twelve to sixteen per table, filling the hue ring's four widest gaps with lime, jade, indigo and magenta at each table's own saturation and brightness. That gives the Style… popover's background grid a third row and the tint grid its third row of four, and both grids gain an Other… row onto the system colour picker — which the card sidebar's combo has had all along and the primary styling surface never did. An arbitrary hex already round-tripped; it is asserted now, including that an unquoted one is a YAML comment and no value. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
268 lines
15 KiB
Swift
268 lines
15 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"), **grown to sixteen each on 2026-08-09**.
|
||
///
|
||
/// ### The two tables are one structure
|
||
///
|
||
/// Each is **four neutrals plus a twelve-stop hue ring**, and the two rings are paired stop for
|
||
/// stop — `carnation`↔`light-cayenne`, `fern`↔`smokey-fern`, `light-teal`↔`dark-teal`,
|
||
/// `pale-violet`↔`smokey-rich-eggplant`. The tint ring is light and the background ring is deep;
|
||
/// otherwise they name the same hues in the same order. Anything added to one belongs in the other
|
||
/// at the same position, which is why the 2026-08-09 additions come in pairs.
|
||
///
|
||
/// ### How the four new stops were chosen
|
||
///
|
||
/// Not by taste — by the holes. The original ring sat at 352°, 19°, 38°, 110°, 180°, 205° and 268°
|
||
/// (plus one desaturated slate at 218° that is a neutral in all but name), and its four widest gaps
|
||
/// were 38→110, 268→352, 110→205 and 205→268. So: **yellow-green at 64°, jade at 158°, indigo at
|
||
/// 238°, magenta at 312°**, each inserted at its hue position rather than appended, so the ring
|
||
/// stays a ring. Saturation and brightness were set to each table's own existing statistics rather
|
||
/// than picked by eye, which is what keeps the new swatches *inside* the family: the tints land at
|
||
/// 2.18–4.67 contrast on white against the old set's 2.18–4.94, and the backgrounds at 6.6–14.7
|
||
/// against the old 5.7–16.6. `rich-lime` is deliberately darkened to `smokey-tangerine`'s exact
|
||
/// 2.18 — an untamed yellow-green would have been the brightest thing in the table by a wide
|
||
/// margin.
|
||
///
|
||
/// The design's AA promise is not a claim this file makes; it is `ContrastMathTests`' two computed
|
||
/// assertions over `backgrounds`, which is precisely what made growing the table a safe thing to do.
|
||
///
|
||
/// ### 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`) — **four neutrals, then the hue ring, then the one muted
|
||
/// slate**, in that order. See `Palette`'s own doc comment for the ring and how the four
|
||
/// 2026-08-09 additions were placed in it.
|
||
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: "rich-lime", hex: "#ADB812"),
|
||
PaletteColor(name: "fern", hex: "#50B23D"),
|
||
PaletteColor(name: "light-jade", hex: "#21B880"),
|
||
PaletteColor(name: "light-teal", hex: "#00B7B7"),
|
||
PaletteColor(name: "deep-sky-blue", hex: "#0084E5"),
|
||
PaletteColor(name: "rich-indigo", hex: "#6065E6"),
|
||
PaletteColor(name: "pale-violet", hex: "#8C59C5"),
|
||
PaletteColor(name: "rich-magenta", hex: "#D952BE"),
|
||
PaletteColor(name: "deep-cool-granite", hex: "#597199"),
|
||
]
|
||
|
||
/// Background palette (`background`) — the wells the style editor offers, "every pair
|
||
/// AA-verified at design time" (03-board-ui.md § Styling ▸ Controls). The verification is
|
||
/// `ContrastMathTests`' two computed assertions over this table, which is what makes growing it
|
||
/// safe: a new well with no readable ink fails there rather than shipping.
|
||
///
|
||
/// Same shape as `foregrounds` and paired to it hue for hue — see `Palette`'s doc comment.
|
||
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-lime", hex: "#5B610E"),
|
||
PaletteColor(name: "smokey-fern", hex: "#145312"),
|
||
PaletteColor(name: "dark-jade", hex: "#035437"),
|
||
PaletteColor(name: "dark-teal", hex: "#005152"),
|
||
PaletteColor(name: "smokey-ocean", hex: "#003168"),
|
||
PaletteColor(name: "smokey-indigo", hex: "#14177A"),
|
||
PaletteColor(name: "smokey-rich-eggplant", hex: "#290659"),
|
||
PaletteColor(name: "smokey-magenta", hex: "#5C074B"),
|
||
PaletteColor(name: "intense-cool-shale", hex: "#1F2E45"),
|
||
]
|
||
|
||
/// The tint names the icon-colour pickers offer: `foregrounds` **minus the four neutrals and
|
||
/// minus `deep-cool-granite`**.
|
||
///
|
||
/// A symbol's *tint* wants colour — "no tint" is the None well's job, not a grey's — and the
|
||
/// mutedest entry of the ring is the one that reads least like a deliberate choice at glyph
|
||
/// size. Eleven remain, which is exactly what a leading None needs to fill a 4×3 grid
|
||
/// (`SymbolPickerLayout`), and that is not a coincidence: the ring was grown to sixteen partly
|
||
/// so this row could gain its third row (2026-08-09).
|
||
///
|
||
/// Derived rather than hand-listed, so a palette addition joins the tint row automatically and
|
||
/// a rename can never leave a dead name behind — the failure mode the old hand-written constant
|
||
/// had.
|
||
static let tints: [PaletteColor] = {
|
||
let neutrals: Set<String> = ["obsidian", "aluminum", "soapstone", "chalk", "deep-cool-granite"]
|
||
return foregrounds.filter { !neutrals.contains($0.name) }
|
||
}()
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|