Both the symbol picker and the background color control give up the two-zone combo chrome from the 2026-08-09 rework: no more face/trigger split, no more trailing chevron square. Each is now a single bordered rectangle with one hit zone, 2.1em tall and 1.5x that wide (a 4:6 ratio, 50% taller than the retired chrome's 18pt). A click anywhere opens the same curated popover the trigger used to gate. The background rectangle's popover is new: a None-plus-sixteen palette grid mirroring the Style… popover's own wells, with an Other… row onto the shared, pre-debounced Colors panel session — replacing the old NSMenu dropdown outright. The symbol rectangle keeps its existing popover (search, curated grid, tint colors, More Symbols…) verbatim; only its entry point collapsed to one zone. ComboFieldControl/ComboFieldMetrics (ComboField.swift) are replaced by PickerRectControl/PickerRectMetrics (PickerRect.swift). ColorComboView and its NSMenu-building pure model are retired wholesale in favor of ColorSwatchPicker. SymbolComboControl becomes SymbolGlyphControl. PaletteSwatch.rectImage, the last caller of which was the retired dropdown's menu rows, goes with it. In the card window sidebar, the "Style" section header becomes "Appearance" (sidebar only — the board context menu's Style… item and StyleEditorView's own naming are untouched), and the symbol and background controls move from stacked rows to side-by-side columns, each captioned above rather than leading. CardStyleSection no longer carries its own debounce Task for background panel picks — the shared Colors-panel session now delivers an already-settled value. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
249 lines
14 KiB
Swift
249 lines
14 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 rectangle (`ColorSwatchPicker`,
|
||
// ColorSwatchPicker.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.
|
||
|
||
// 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.
|
||
///
|
||
/// **`rectImage`, the wide sibling this enum used to carry, is retired (2026-08-10).** It built the
|
||
/// `NSMenuItem` images for `ColorComboView`'s dropdown rows and its collapsed face — both gone with
|
||
/// that control (`ColorSwatchPicker.swift`'s own header). `circleImage` below is the one caller left:
|
||
/// the quick-style recents row is still a menu, and a menu can still only render `Image`/`Text`.
|
||
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
|
||
}
|
||
}
|
||
}
|
||
|
||
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 `SystemColorPanel`'s own 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)
|
||
}
|
||
}
|