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:
@@ -19,11 +19,18 @@ import os
|
||||
/// `.missingIndex` warning, and only a UUID-shaped candidate's `index.md` can fail-fast.
|
||||
///
|
||||
/// Reserved child names (`attachments/`, `comments/`) only matter as children *of a card*
|
||||
/// (01-storage-format.md § Fractal layout ▸ Rules); since cards are leaves here — this loader
|
||||
/// never scans a card folder's contents beyond checking for `index.md` — that reservation is
|
||||
/// satisfied by construction and needs no explicit filtering. Doubly so under the shape rule:
|
||||
/// were a card folder ever scanned, `attachments` and `comments` are non-UUID-shaped and would
|
||||
/// read as strays, not levels — so they never need special-casing against the stray warning.
|
||||
/// (01-storage-format.md § Fractal layout ▸ Rules), and cards are leaves *structurally*: the
|
||||
/// walk stops at depth 2, so nothing below a card is ever a level candidate. Doubly so under the
|
||||
/// shape rule — `attachments` and `comments` are non-UUID-shaped and would read as strays, not
|
||||
/// levels, so they never need special-casing against the stray warning.
|
||||
///
|
||||
/// **The one read inside a card folder** is `attachmentNames(in:)`: a single flat listing of
|
||||
/// `attachments/`, feeding `Card.attachments`. It is a *names* read and nothing more — it never
|
||||
/// opens a file, never descends, never warns, and degrades to `[]` on any failure. Two board-
|
||||
/// window surfaces need it before a card window exists (the face's paperclip indicator and the
|
||||
/// sole-selected card's carousel — 03-board-ui.md § Card face), and the snapshot is where they
|
||||
/// read from. Everything else about a card folder's contents remains outside this loader's
|
||||
/// business.
|
||||
///
|
||||
/// Symlinks: a lane/card candidate that is itself a symlink is treated as a stray and never
|
||||
/// followed, whether it points to a file or a directory — this loader does not resolve
|
||||
@@ -119,6 +126,7 @@ public enum BoardLoader: Sendable {
|
||||
icon: cardDocument.icon,
|
||||
iconColor: cardDocument.iconColor,
|
||||
order: cardOrder,
|
||||
attachments: attachmentNames(in: cardURL),
|
||||
document: cardDocument
|
||||
))
|
||||
}
|
||||
@@ -176,6 +184,52 @@ public enum BoardLoader: Sendable {
|
||||
FileManager.default.fileExists(atPath: folder.appendingPathComponent(indexFileName).path)
|
||||
}
|
||||
|
||||
/// The names of `<card>/attachments/`'s **top-level regular files** — the flat view
|
||||
/// 01-storage-format.md § Attachments specifies ("top-level files only"; "subfolders are
|
||||
/// tolerated, preserved verbatim … and not surfaced"). `[]` when there is no `attachments/`.
|
||||
///
|
||||
/// Three exclusions, the same three `directoryCandidates` makes and for the same reasons:
|
||||
/// hidden entries (`.DS_Store` and friends are not the user's attachments), directories (a
|
||||
/// subfolder stays reachable through Reveal in Finder and through body-relative paths, but
|
||||
/// never appears as an attachment), and symlinks (this loader resolves nothing — the same
|
||||
/// stance the level walk takes).
|
||||
///
|
||||
/// **Finder order** (`localizedStandardCompare`), so `"shot 2.png"` sorts before
|
||||
/// `"shot 10.png"`: the order has to be stable across loads for the face carousel's pages and
|
||||
/// its dots, and where it is already the sidebar's order it may as well be the same one.
|
||||
///
|
||||
/// Failure is silent: an unlistable directory yields `[]`. Fail-fast is reserved for
|
||||
/// structure (01-storage-format.md § Malformed input), and this field decorates a card — a
|
||||
/// permissions race here must never be the reason a whole board refuses to open.
|
||||
///
|
||||
/// Internal rather than `private`: `BoardWriter.listAttachments` — the card window sidebar's
|
||||
/// authoritative listing — answers through this same function behind its own card-folder
|
||||
/// guard, so the face and the sidebar can never disagree about what a card's attachments are.
|
||||
/// It is also why the folder name is read off `BoardWriter`, which owns it as the one folder
|
||||
/// the app ever creates under a card.
|
||||
static func attachmentNames(in cardFolder: URL) -> [String] {
|
||||
let folder = cardFolder.appendingPathComponent(
|
||||
BoardWriter.attachmentsFolderName, isDirectory: true
|
||||
)
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||
at: folder,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
return entries
|
||||
.filter { url in
|
||||
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
|
||||
return false
|
||||
}
|
||||
return values.isRegularFile == true && values.isSymbolicLink != true
|
||||
}
|
||||
.map(\.lastPathComponent)
|
||||
.sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// The hex characters `isUUIDShaped` accepts in each `-`-delimited group — **both cases**,
|
||||
/// per the shape-only identity predicate below.
|
||||
private static let uuidGroupCharacters = Set("0123456789abcdefABCDEF")
|
||||
|
||||
@@ -151,9 +151,11 @@ public struct Lane: Identifiable, Sendable, Equatable {
|
||||
public var isDeleted: Bool { !deleted.isMissing }
|
||||
}
|
||||
|
||||
/// A card: `<root>/<guid>/<guid>/index.md`. Leaf of the fractal tree — `attachments/` and the
|
||||
/// (future, out-of-scope) `comments/` live alongside `index.md` on disk but are not modeled
|
||||
/// here.
|
||||
/// A card: `<root>/<guid>/<guid>/index.md`, plus the *names* of its attachments. Structurally
|
||||
/// still a leaf — `comments/` (future, out-of-scope) and the attachment files' contents live
|
||||
/// alongside `index.md` on disk and are not modeled here; `attachments` is the one thing the
|
||||
/// snapshot reaches inside a card folder for, because two board-window surfaces need it before
|
||||
/// any card window exists (see its own doc comment).
|
||||
public struct Card: Identifiable, Sendable, Equatable {
|
||||
public let id: ItemID
|
||||
|
||||
@@ -171,6 +173,25 @@ public struct Card: Identifiable, Sendable, Equatable {
|
||||
/// comment; the same reasoning applies here.
|
||||
public let order: Double
|
||||
|
||||
/// The card's attachment file names — **flat: top-level regular files only, in Finder
|
||||
/// order** (01-storage-format.md § Attachments: "the app's attachment surfaces … are flat:
|
||||
/// top-level files only", and "subfolders are tolerated, preserved verbatim, never created
|
||||
/// by the app, and not surfaced"). Empty when the card has no `attachments/` folder, and
|
||||
/// empty when it has one that couldn't be listed — the field is cosmetic, so a
|
||||
/// directory-listing race degrades to "nothing to show" rather than failing a load.
|
||||
///
|
||||
/// Names, not URLs: the two board-window consumers only need to know *whether*, *how many*,
|
||||
/// and in *what order* — the face's quiet paperclip indicator (03-board-ui.md § Card face)
|
||||
/// and the sole-selected card's attachment carousel. The card window's sidebar does its own
|
||||
/// listing through `BoardWriter.listAttachments`, since it acts on the files rather than
|
||||
/// showing them; that call answers through the very same enumeration
|
||||
/// (`BoardLoader.attachmentNames(in:)`), so the two surfaces agree by construction.
|
||||
///
|
||||
/// Freshness is the watcher's, by construction: it reloads on any change anywhere under the
|
||||
/// board root, so an attachment added in Finder rebuilds the snapshot exactly like an edited
|
||||
/// `index.md` does — no separate invalidation path to keep honest.
|
||||
public let attachments: [String]
|
||||
|
||||
/// The full parsed `index.md`; unknown/reserved keys ride along uninterpreted.
|
||||
public let document: FrontmatterDocument
|
||||
|
||||
|
||||
@@ -1002,26 +1002,17 @@ public enum BoardWriter: Sendable {
|
||||
/// directory, *is* `.unreadable` — that is a caller asking about a card that isn't there,
|
||||
/// not an empty listing. Purely a read: nothing here ever creates `attachments/` or
|
||||
/// disturbs anything inside it, subfolders included.
|
||||
///
|
||||
/// The listing itself is `BoardLoader.attachmentNames(in:)`, which is also what fills
|
||||
/// `Card.attachments` for the board window's face indicator and carousel. **One function, so
|
||||
/// the sidebar and the face can never disagree** about a card's attachments or their order.
|
||||
/// What this call adds over that one is the card-folder guard and this API's error
|
||||
/// vocabulary — the difference between a surface that *acts* on the files and two that
|
||||
/// merely show them.
|
||||
public static func listAttachments(ofCard cardFolder: URL) throws(BoardWriteError) -> [String] {
|
||||
let operation = WriteOperation.listAttachments
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||
|
||||
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||
at: attachmentsFolder,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let files = entries.filter { url in
|
||||
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
|
||||
return false
|
||||
}
|
||||
return values.isRegularFile == true && values.isSymbolicLink != true
|
||||
}
|
||||
return files.map(\.lastPathComponent).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
return BoardLoader.attachmentNames(in: cardFolder)
|
||||
}
|
||||
|
||||
// MARK: - Move/copy pre-flight
|
||||
|
||||
+143
-39
@@ -35,10 +35,9 @@ struct LaneHeaderDrag {
|
||||
/// ### What is still a later card's
|
||||
///
|
||||
/// The lane context menu (Rename, Style…, the quick-style recents row, the Width stepper, Delete),
|
||||
/// the colour accent band, and the search-aware filtering behind the count all belong to later
|
||||
/// milestones. The **card face** is likewise still a stub — `CardStubView` gains the leading icon,
|
||||
/// the attachment chip, the cut treatment and the attachment carousel with the card-face card; what
|
||||
/// it grows here is only what inline rename and click selection require.
|
||||
/// the lane's own top-edge accent band, and the search-aware filtering behind the count all belong
|
||||
/// to later milestones. The card face is real (`CardFaceView`); what it still owes is the cut
|
||||
/// treatment and the sole-selected card's attachment carousel.
|
||||
struct LaneView: View {
|
||||
|
||||
let store: BoardStore
|
||||
@@ -209,7 +208,7 @@ struct LaneView: View {
|
||||
ForEach(slots) { slot in
|
||||
switch slot {
|
||||
case let .card(card):
|
||||
CardStubView(store: store, card: card, openCard: openCard)
|
||||
CardFaceView(store: store, card: card, openCard: openCard)
|
||||
case .placeholder:
|
||||
NewCardStubView(store: store, openCard: openCard)
|
||||
}
|
||||
@@ -328,52 +327,67 @@ private enum LaneSlot: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card stub
|
||||
// MARK: - Card face
|
||||
|
||||
/// A card, as a rounded plate with its title — **still a stand-in**, replaced by the card-face card,
|
||||
/// which brings the leading icon, the attachment chip, the cut treatment and the sole-selected
|
||||
/// card's attachment carousel (03-board-ui.md § Card face).
|
||||
/// The card face: a rounded plate carrying a leading SF Symbol, the title (or its quiet "Untitled"
|
||||
/// placeholder), a quiet trailing attachments indicator, and a left-edge colour accent stripe
|
||||
/// (03-board-ui.md § Card face, § Styling ▸ Capabilities).
|
||||
///
|
||||
/// What it has grown here is only what this milestone owes: click-to-select with a selection
|
||||
/// treatment, and the inline rename editor swapping in for the title when this card is the rename
|
||||
/// target.
|
||||
private struct CardStubView: View {
|
||||
/// ### Title-only, deliberately
|
||||
///
|
||||
/// **No body excerpt** — settled, "the face stays title-only … the old 'iterate on the card face
|
||||
/// later' item is closed with no growth". The only face chip in scope is attachments, "a quiet
|
||||
/// indicator when the card has files — the title dominates", which is why the paperclip is a
|
||||
/// secondary-tinted caption and not a count pill: the eye should land on the title.
|
||||
///
|
||||
/// ### Two lenient fields, two different fallbacks
|
||||
///
|
||||
/// `icon` and `iconColor` are hand-written-only on cards (`iconColor` is **schema yes, control
|
||||
/// no** — the app never offers a picker for it, but honours what an author writes). Both degrade
|
||||
/// rather than fail: an unknown symbol name draws the level default (`ItemSymbol`), and a colour
|
||||
/// value that resolves to nothing draws the standard secondary tint. `background` degrades a third
|
||||
/// way — to **no stripe at all** — because there is no sensible default colour for "the author
|
||||
/// meant something we can't read", and a wrong colour is worse than none. In every case the bytes
|
||||
/// on disk are untouched (`Palette`, 01-storage-format.md § Frontmatter).
|
||||
///
|
||||
/// ### Room for the carousel
|
||||
///
|
||||
/// The face is a top-aligned `VStack` and its two decorations — the accent stripe and the selection
|
||||
/// stroke — are shapes in overlays, so both stretch to whatever height the content takes. That is
|
||||
/// what lets m5's carousel expand *inside* this card without any of it being re-derived: the
|
||||
/// masonry already isolates column heights, so a taller card pushes only the cards below it in its
|
||||
/// own column.
|
||||
private struct CardFaceView: View {
|
||||
|
||||
let store: BoardStore
|
||||
let card: Card
|
||||
let openCard: (ItemID) -> Void
|
||||
|
||||
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
|
||||
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
|
||||
private let cornerRadius: CGFloat = 8
|
||||
|
||||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
|
||||
/// treatment shootout).
|
||||
private let stripeWidth: CGFloat = 4
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isRenaming {
|
||||
InlineTitleField(
|
||||
text: draft,
|
||||
prompt: "Card title",
|
||||
onCommit: { store.commitRename() },
|
||||
onAbandon: { store.transient.discardRename() },
|
||||
// **Click-away commits** — a rename's rule, and the deliberate opposite of the
|
||||
// placeholder's (04-interactions.md ▸ Grammar: "focus loss = commit, matching
|
||||
// the card window's title field").
|
||||
onFocusLoss: { store.commitRename() },
|
||||
onCommitAndOpen: {
|
||||
let id = card.id
|
||||
store.commitRename()
|
||||
openCard(id)
|
||||
}
|
||||
)
|
||||
.font(.body)
|
||||
} else {
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
||||
.lineLimit(4)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
titleRow
|
||||
// m5-carousel: the sole selected card's paged attachment carousel expands here — below
|
||||
// the title, inside this same plate, keyed on the selection transaction
|
||||
// (03-board-ui.md § Card face). It needs `card.attachments` (already loaded) and this
|
||||
// view's `isSelected`; nothing above it changes.
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(10)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
|
||||
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
|
||||
// colouring a card never shifts its title relative to its uncoloured neighbours.
|
||||
.padding(.leading, stripeWidth)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
@@ -384,6 +398,96 @@ private struct CardStubView: View {
|
||||
.onTapGesture { store.select([card.id], liveness: .live) }
|
||||
}
|
||||
|
||||
// MARK: - Title row
|
||||
|
||||
private var titleRow: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
titleOrEditor
|
||||
// The title takes the row's width so the indicator sits hard against the trailing
|
||||
// edge — and so the rename field fills the same span the title occupied.
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
}
|
||||
|
||||
/// The title, or the rename editor when this card is the rename target. Unchanged from the
|
||||
/// stub this face replaces: the four exits and their store calls are 04-interactions.md ▸
|
||||
/// Grammar's, stated once in `InlineTitleField`.
|
||||
@ViewBuilder
|
||||
private var titleOrEditor: some View {
|
||||
if isRenaming {
|
||||
InlineTitleField(
|
||||
text: draft,
|
||||
prompt: "Card title",
|
||||
onCommit: { store.commitRename() },
|
||||
onAbandon: { store.transient.discardRename() },
|
||||
// **Click-away commits** — a rename's rule, and the deliberate opposite of the
|
||||
// placeholder's (04-interactions.md ▸ Grammar: "focus loss = commit, matching
|
||||
// the card window's title field").
|
||||
onFocusLoss: { store.commitRename() },
|
||||
onCommitAndOpen: {
|
||||
let id = card.id
|
||||
store.commitRename()
|
||||
openCard(id)
|
||||
}
|
||||
)
|
||||
.font(.body)
|
||||
} else {
|
||||
Text(card.title.value ?? "Untitled")
|
||||
.font(.body)
|
||||
.foregroundStyle(card.title.value == nil ? .secondary : .primary)
|
||||
.lineLimit(4)
|
||||
}
|
||||
}
|
||||
|
||||
/// `iconColor`'s tint, or the standard secondary one. Deliberately not `AnyShapeStyle(.primary)`
|
||||
/// on the fallback path: an uncoloured card icon is chrome, and chrome is secondary — the tint
|
||||
/// exists to make a *hand-coloured* icon stand out from its neighbours.
|
||||
private var iconTint: AnyShapeStyle {
|
||||
if let color = Palette.color(for: card.iconColor) {
|
||||
AnyShapeStyle(color)
|
||||
} else {
|
||||
AnyShapeStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
/// The one face chip in scope — shown only when the card actually has files, and quiet enough
|
||||
/// that the title still dominates (03-board-ui.md § Card face). The count goes to the
|
||||
/// accessibility label rather than onto the face: it is useful to know, not to look at.
|
||||
@ViewBuilder
|
||||
private var attachmentsIndicator: some View {
|
||||
if !card.attachments.isEmpty {
|
||||
Image(systemName: "paperclip")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityLabel("\(card.attachments.count) attachments")
|
||||
}
|
||||
}
|
||||
|
||||
/// K1 · left edge stripe, painted with the resolved `background` — "a card's [colour paints] a
|
||||
/// stripe along its left edge; the surfaces themselves keep the standard chrome, so coloured
|
||||
/// title text never sits on a coloured fill" (03-board-ui.md § Styling ▸ Capabilities).
|
||||
///
|
||||
/// A value that resolves to nothing — a typo'd palette name, a malformed hex, a sequence where
|
||||
/// a scalar belongs — draws **no stripe**, and the value stays on disk exactly as written.
|
||||
/// A `Shape` rather than a sized rectangle so it takes the plate's full height whatever the
|
||||
/// content does, m5's carousel expansion included.
|
||||
@ViewBuilder
|
||||
private var accentStripe: some View {
|
||||
if let color = Palette.color(for: card.background) {
|
||||
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, bottomLeadingRadius: cornerRadius)
|
||||
.fill(color)
|
||||
.frame(width: stripeWidth)
|
||||
// Decoration only: the whole plate is one click target for selection.
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Selection and rename plumbing
|
||||
|
||||
private var isSelected: Bool {
|
||||
store.selection.liveness == .live && store.selection.ids.contains(card.id)
|
||||
}
|
||||
|
||||
@@ -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