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:
2026-07-27 13:48:50 -04:00
parent b35566e0fe
commit b4c90838b4
13 changed files with 692 additions and 69 deletions
+143 -39
View File
@@ -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)
}
+126
View File
@@ -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 namehex 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
)
}
}