Files
rzen b4c90838b4 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
2026-07-27 13:48:50 -04:00

165 lines
8.4 KiB
Swift

import AppKit
import Testing
@testable import Kanban
/// The colour vocabulary `background` and `iconColor` are written in (03-board-ui.md § Styling ▸
/// Capabilities): a kebab-case palette name, or a `#RRGGBB[AA]` hex — and **nil for everything
/// else**, which is a rendering instruction, never an error.
///
/// The palette tables themselves are pinned by name *and* hex rather than merely counted: they
/// are a carried-over design artefact ("the pathfinder's palettes … carry over as the starting
/// point"), and their names are what users hand-write into files — a silent rename or a shifted
/// hex would change what an existing board renders as.
// MARK: - The tables
struct PaletteTableTests {
@Test func bothTablesHoldTheTwelveNamedColoursTheDesignCarriesOver() {
#expect(Palette.foregrounds.map(\.name) == [
"obsidian", "aluminum", "soapstone", "chalk",
"carnation", "rich-grapefruit", "smokey-tangerine", "fern",
"light-teal", "deep-sky-blue", "pale-violet", "deep-cool-granite",
])
#expect(Palette.backgrounds.map(\.name) == [
"obsidian", "shale", "aluminum", "chalk",
"light-cayenne", "light-mocha", "smokey-mocha", "smokey-fern",
"dark-teal", "smokey-ocean", "smokey-rich-eggplant", "intense-cool-shale",
])
}
@Test func everyPaletteNameResolvesToItsOwnHex() throws {
for entry in Palette.foregrounds + Palette.backgrounds {
let byName = try #require(
Palette.nsColor(for: entry.name),
"palette name '\(entry.name)' did not resolve"
)
let byHex = try #require(
NSColor(paletteHex: entry.hex),
"palette hex '\(entry.hex)' for '\(entry.name)' is not parseable"
)
#expect(byName == byHex, "'\(entry.name)' resolved to something other than \(entry.hex)")
}
}
/// The three names appearing in both tables are the same colour in each — `obsidian`,
/// `aluminum` and `chalk` are one colour with one hex, listed twice because both *pickers*
/// offer them. Resolution searches foregrounds first, so a drift would make the same written
/// name mean two different things depending on which field it landed in.
@Test func namesSharedByBothTablesCarryOneHex() {
for name in ["obsidian", "aluminum", "chalk"] {
let foreground = Palette.foregrounds.first { $0.name == name }?.hex
let background = Palette.backgrounds.first { $0.name == name }?.hex
#expect(foreground != nil && foreground == background, "'\(name)' differs between the two tables")
}
}
/// Both tables answer either field: the foreground/background split is what each picker
/// offers, not a namespace (`Palette.nsColor(for:)`). A hand-written `background: carnation`
/// — a name only the icon-tint table lists — must resolve, not read as garbage.
@Test func aNameFromEitherTableResolvesRegardlessOfWhichFieldItCameFrom() {
#expect(Palette.nsColor(for: "carnation") != nil) // foregrounds only
#expect(Palette.nsColor(for: "intense-cool-shale") != nil) // backgrounds only
}
}
// MARK: - Name matching
struct PaletteNameMatchingTests {
/// Names are matched **exactly**, kebab-case as the tables spell them. A near-miss degrades
/// like any other unknown value rather than being guessed at — the same posture `ItemSymbol`
/// takes towards a typo'd symbol name.
@Test func nameMatchingIsCaseSensitiveAndExact() {
#expect(Palette.nsColor(for: "deep-sky-blue") != nil)
#expect(Palette.nsColor(for: "Deep-Sky-Blue") == nil)
#expect(Palette.nsColor(for: "DEEP-SKY-BLUE") == nil)
#expect(Palette.nsColor(for: "deep sky blue") == nil)
#expect(Palette.nsColor(for: "deepskyblue") == nil)
#expect(Palette.nsColor(for: " fern") == nil)
#expect(Palette.nsColor(for: "fern ") == nil)
}
/// `color(for:)` folds all three `FieldValue` shapes into one answer, exactly as
/// `ItemSymbol.name(_:fallback:)` does: to a renderer, a missing key, a malformed one, and a
/// valid-but-unknown name are one case — *there is no colour, so use your default*.
@Test func everyUnusableFieldShapeReadsAsNoColour() {
#expect(Palette.color(for: .valid("fern")) != nil)
#expect(Palette.color(for: .valid("#FF0000")) != nil)
#expect(Palette.color(for: FieldValue<String>.missing) == nil)
#expect(Palette.color(for: .malformed(raw: "[a, b]")) == nil)
#expect(Palette.color(for: .valid("chartreuse")) == nil)
}
}
// MARK: - Hex parsing
/// `#RRGGBB[AA]` in **sRGB** — the colour space the digits name, so a value sampled from a
/// screenshot renders as the colour the author sampled.
struct PaletteHexTests {
private func components(_ hex: String) throws -> (CGFloat, CGFloat, CGFloat, CGFloat) {
let color = try #require(NSColor(paletteHex: hex), "'\(hex)' did not parse")
let srgb = try #require(color.usingColorSpace(.sRGB), "'\(hex)' is not sRGB-convertible")
return (srgb.redComponent, srgb.greenComponent, srgb.blueComponent, srgb.alphaComponent)
}
private func expectComponents(
_ hex: String,
_ expected: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat)
) throws {
let (red, green, blue, alpha) = try components(hex)
let tolerance: CGFloat = 0.001
#expect(abs(red - expected.red) < tolerance, "\(hex): red \(red) != \(expected.red)")
#expect(abs(green - expected.green) < tolerance, "\(hex): green \(green) != \(expected.green)")
#expect(abs(blue - expected.blue) < tolerance, "\(hex): blue \(blue) != \(expected.blue)")
#expect(abs(alpha - expected.alpha) < tolerance, "\(hex): alpha \(alpha) != \(expected.alpha)")
}
@Test func sixDigitHexParsesToItsSRGBComponentsAtFullOpacity() throws {
try expectComponents("#FF0000", (red: 1, green: 0, blue: 0, alpha: 1))
try expectComponents("#00FF00", (red: 0, green: 1, blue: 0, alpha: 1))
try expectComponents("#0000FF", (red: 0, green: 0, blue: 1, alpha: 1))
try expectComponents("#000000", (red: 0, green: 0, blue: 0, alpha: 1))
try expectComponents("#FFFFFF", (red: 1, green: 1, blue: 1, alpha: 1))
// The rich-board fixture's board background — an ordinary hand-written value.
try expectComponents("#1E1E1E", (red: 30 / 255, green: 30 / 255, blue: 30 / 255, alpha: 1))
}
@Test func eightDigitHexParsesItsTrailingPairAsAlpha() throws {
try expectComponents("#FF000080", (red: 1, green: 0, blue: 0, alpha: 128 / 255))
try expectComponents("#00FF00FF", (red: 0, green: 1, blue: 0, alpha: 1))
try expectComponents("#0000FF00", (red: 0, green: 0, blue: 1, alpha: 0))
}
/// Hex digits are case-insensitive — unlike palette *names*. Two different vocabularies with
/// two different rules, deliberately: `#ff0000` is the same number as `#FF0000`, where
/// `Fern` is simply not a name the palette has.
@Test func hexDigitsAreCaseInsensitive() throws {
let lower = try #require(NSColor(paletteHex: "#a1b2c3"))
let upper = try #require(NSColor(paletteHex: "#A1B2C3"))
#expect(lower == upper)
}
/// Garbage resolves to nothing — no throw, no default colour, no partial read of a truncated
/// value. Every one of these leaves the bytes on disk untouched and the surface undecorated.
@Test func malformedAndUnknownValuesResolveToNil() {
for value in [
"", // the empty string
"#", // a lone marker
"#12", // too short
"#12345", // five digits
"#1234567", // seven — neither RGB nor RGBA
"#123456789", // nine
"#GGGGGG", // right length, not hex
"#12345G", // one bad digit
"not-a-color", // kebab-case, but not a name the palette has
"FF0000", // hex digits without the '#' read as a name, and no name matches
"rgb(255,0,0)", // a different colour vocabulary entirely
] {
#expect(Palette.nsColor(for: value) == nil, "'\(value)' should not resolve")
#expect(Palette.color(named: value) == nil, "'\(value)' should not resolve to a SwiftUI Color")
}
}
}