Build card faces with edge-accent styling
The card face becomes real: leading SF Symbol (card default doc.text, tinted by a valid hand-written iconColor — schema yes, control no), title or the quiet untitled placeholder, and a quiet paperclip when the card has attachments — title-only by design, no body excerpt. Color is the settled K1 edge accent, not a fill: background paints a 4pt stripe down the left edge, resolved through the ported pathfinder palette (12 icon tints + 12 backgrounds carried over verbatim, plus raw #RRGGBB[AA]); anything unresolvable paints nothing and stays on disk exactly as written. The snapshot now carries each card's flat attachment names — the loader's one read inside a card folder, shared with the Writer's listing so the m5 carousel and m6 sidebar can never disagree on order (Finder order, the Writer's existing comparator). The face keeps its top-aligned structure so the sole-selection carousel can expand inside the card without moving masonry neighbors. 18 new tests. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
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:)`)
|
||||
// and its swatch drawing are deliberately not ported yet: nothing writes a colour until the style
|
||||
// editor lands, and an unused writer is a claim about a surface that doesn't exist. The styling
|
||||
// card brings them back when the editor needs them.
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user