Files
lanework/Kanban/UI/Palette.swift
T
rzen c6298c2e41 Build the styling system and shared style editor
One style-editor component, anchor-agnostic: a background grid (None
well plus the 12 palette colors) and a curated symbol grid (the
pathfinder's five-dozen set, leading well removing the icon key for
the level default), selection-aware across cards, lanes, and the
board itself. Batch edits compute per-dimension state — uniform,
mixed (no well selected), or an off-palette value labeled verbatim
outside the grids — and choosing a well applies to the whole target
set as one write bracket, skipping no-ops per field. The popover
tracks its target set live per the freshly ratified rule: targets
re-resolve by UUID on every reload, a vanished target leaves the set,
an emptied set dismisses the editor, and nothing ever silently
retargets to the board. Anchors landing now: Board > Style
(Opt-Cmd-S) and the card/lane context menus, which also carry the
quick-style recents row (app-wide, persisted, capped at six, None
never recorded) and the lane's width control twinning the menu
chords. The styling system's other two renders arrive with it: a
lane's background paints the C7 top-edge band, the board's paints
the window content background — malformed values paint nothing and
stay byte-identical on disk. 31 new tests.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
2026-07-27 14:47:26 -04:00

164 lines
8.1 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 (`NSColor.paletteHexString`, `Palette.name(forHex:)`)
// stay unported: they exist to turn a colour the *system picker* returned back into a palette name,
// and this app has no colour picker — "custom hex is not pickable in-app" (03 § Styling ▸ Controls)
// makes the whole round trip a surface that doesn't exist. Its swatch drawing, on the other hand, is
// below: a menu can only render `Image`/`Text`, so the quick-style row's dots 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
}
}
}
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)
}
}
// 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
)
}
}