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
+3 -1
View File
@@ -4,13 +4,15 @@ Golden fixture boards for the storage-contract test suite — real on-disk folde
Bundled into the unit-test target as a folder reference (see `project.yml`). Valid boards live under `Valid/`, fail-fast cases under `Malformed/`. Tests live in `KanbanTests/FixtureBoardTests.swift`.
Hidden fixture files are named `.hidden-*` rather than `.DS_Store`: the repo's `.gitignore` ignores `.DS_Store` everywhere, so a fixture spelled that way would exist on the authoring machine and vanish from a fresh clone. The loader's rule is `.skipsHiddenFiles` — it is about the leading dot, not the name.
Lane/card folder names are fixed literal lowercase-UUIDv4-shaped strings (never generated at test time), chosen so their lexicographic order matches the expected tie-break order — usually a leading digit (`10000000-...`, `20000000-...`, …) so folder order reads the same as array-index order in the tests. That is a fixture-authoring convention, not the loader's gate: the identity predicate is shape-only (`8-4-4-4-12` hex, **any case, any version** — 01-storage-format.md § Fractal layout ▸ Rules), and the case/version coverage lives in `KanbanTests/BoardLoaderTests.swift` rather than here.
## Valid/ — one board per tolerated/valid case
| Board | Case |
|---|---|
| `rich-board.kanban` | A full-breadth well-formed board: 2 lanes, 3 cards, bodies, styling (background/icon/iconColor/width), unknown + reserved frontmatter keys, `attachments/` and `comments/` with real content. Also the board every `index.md` in the tree is round-tripped against. |
| `rich-board.kanban` | A full-breadth well-formed board: 2 lanes, 3 cards, bodies, styling (background/icon/iconColor/width), unknown + reserved frontmatter keys, `attachments/` and `comments/` with real content. Its `attachments/` also carries all four listing shapes — two ordinary files, a hidden one, and a subfolder with a file — so `Card.attachments`' flat rule (01-storage-format.md § Attachments) is asserted against a real tree. Also the board every `index.md` in the tree is round-tripped against. |
| `interrupted-create.kanban` | The motivating skip-not-error case: a UUID-shaped lane folder and a UUID-shaped card folder, each with no `index.md` yet (folder created, write not yet landed). |
| `non-uuid-strays.kanban` | Non-UUID-shaped folders at both lane and card depth, with and without `index.md` — name shape gates candidacy before the file is ever read. |
| `stray-files.kanban` | Stray (non-directory) files at board, lane, and card level — never level candidates, never warned about. |
@@ -0,0 +1 @@
Hidden entries are never attachments (skipsHiddenFiles).
@@ -0,0 +1 @@
Not a real .txt either — a stand-in attachment.
@@ -0,0 +1 @@
A subfolder file: tolerated, preserved, never surfaced.
+59 -5
View File
@@ -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")
+24 -3
View File
@@ -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
+8 -17
View File
@@ -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
+131 -27
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,23 +327,97 @@ 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 {
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)
// 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: cornerRadius)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
)
.contentShape(Rectangle())
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
// two-stage Finder rename): one click selects and that is all it does no timer, no
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
// Board Rename.
.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,
@@ -369,20 +442,51 @@ private struct CardStubView: View {
.lineLimit(4)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
)
.contentShape(Rectangle())
// **Clicking never edits** (04-interactions.md Selection, a pivot from the pathfinder's
// two-stage Finder rename): one click selects and that is all it does no timer, no
// slow-second-click rename, no accidental edit on a hesitant click. Rename is Return or
// Board Rename.
.onTapGesture { store.select([card.id], liveness: .live) }
/// `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
)
}
}
+129 -4
View File
@@ -390,9 +390,10 @@ struct BoardLoaderNonUUIDStrayTests {
}
}
/// Reserved card children are covered "by construction" now: `attachments/` and
/// `comments/` are non-UUID-shaped, and this loader never scans a card folder's contents
/// anyway (cards are leaves) either way, they must never surface a warning.
/// Reserved card children are covered "by construction": `attachments/` and `comments/` are
/// non-UUID-shaped, and the *level walk* stops at depth 2 so neither can ever be mistaken
/// for an item, and neither may surface a warning. (`attachments/` is read for its file
/// names, which is a listing, not a descent see `CardAttachmentListingTests` below.)
@Test func reservedAttachmentsAndCommentsUnderCardProduceNoWarning() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
@@ -401,7 +402,7 @@ struct BoardLoaderNonUUIDStrayTests {
let card = uuidFolderName()
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)", "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(card)", "schema: 1\norder: 1024\n")
try fixture.strayFile("\(lane)/\(card)/attachments/sketch.png")
try fixture.strayFile("\(lane)/\(card)/comments/whatever.md")
@@ -412,6 +413,130 @@ struct BoardLoaderNonUUIDStrayTests {
}
}
// MARK: - Card attachments (01-storage-format.md § Attachments)
/// `Card.attachments` the loader's one read *inside* a card folder. The golden-fixture suite
/// pins the everyday shapes on real committed trees (`FixtureBoardTests`); these cover what a git
/// fixture can't carry (a symlink) and what only a synthetic tree can arrange (a card folder whose
/// `attachments` is a *file*, an empty folder, Finder's numeric ordering).
struct CardAttachmentListingTests {
/// Builds a one-card board and returns that card, so each test below is one arrangement plus
/// one assertion.
private func card(in fixture: BoardFixture) throws -> Card {
let result = try BoardLoader.load(boardRoot: fixture.root)
return try #require(result.model.lanes.first?.cards.first)
}
private func boardWithOneCard(_ fixture: BoardFixture) throws -> String {
let lane = "10000000-0000-4000-8000-000000000001"
let cardID = "20000000-0000-4000-8000-000000000002"
try fixture.index("", "schema: 1\n")
try fixture.index(lane, "schema: 1\norder: 1024\n")
try fixture.index("\(lane)/\(cardID)", "schema: 1\norder: 1024\n")
return "\(lane)/\(cardID)"
}
/// **Symlinks are not surfaced** the same never-resolve stance the level walk takes
/// (`directoryCandidates`), so a link into another volume or a cycle can't turn a listing
/// into a traversal. The link itself stays on disk untouched; it just isn't an attachment.
@Test func symlinksInAttachmentsAreNotSurfaced() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
try fixture.strayFile("\(cardPath)/attachments/real.png")
try fixture.strayFile("outside.png")
let attachments = fixture.root.appendingPathComponent("\(cardPath)/attachments", isDirectory: true)
try FileManager.default.createSymbolicLink(
at: attachments.appendingPathComponent("link-to-file.png"),
withDestinationURL: fixture.root.appendingPathComponent("outside.png")
)
try FileManager.default.createSymbolicLink(
at: attachments.appendingPathComponent("link-to-folder"),
withDestinationURL: fixture.root
)
#expect(try card(in: fixture).attachments == ["real.png"])
}
/// The four shapes in one folder the fixture board's assertion restated synthetically, so
/// the rule is pinned even if the bundled fixture tree ever loses a file to a copy phase.
@Test func onlyTopLevelNonHiddenRegularFilesAreListed() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
try fixture.strayFile("\(cardPath)/attachments/sketch.png")
try fixture.strayFile("\(cardPath)/attachments/notes.txt")
try fixture.strayFile("\(cardPath)/attachments/.DS_Store")
try fixture.strayFile("\(cardPath)/attachments/sub/nested.txt")
#expect(try card(in: fixture).attachments == ["notes.txt", "sketch.png"])
}
/// **Finder order** (`localizedStandardCompare`), not plain lexicographic digits inside a
/// name *count* rather than collate, so the run `importAttachments` itself produces on a
/// collision (`shot.png` `shot 2.png` `shot 10.png`) pages 2-before-10, and the face's
/// carousel matches the card window sidebar's listing, which answers through this same
/// enumeration. (`shot.png` trailing its own numbered copies is Finder's own ordering of a
/// space against a dot, not a quirk of ours.)
@Test func namesSortInFinderOrderSoNumbersCountRatherThanCollate() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
for name in ["shot 10.png", "shot 2.png", "shot.png", "page-10.txt", "page-2.txt"] {
try fixture.strayFile("\(cardPath)/attachments/\(name)")
}
#expect(try card(in: fixture).attachments == [
"page-2.txt", "page-10.txt", "shot 2.png", "shot 10.png", "shot.png",
])
}
@Test func anEmptyAttachmentsFolderListsNothing() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
try fixture.emptyFolder("\(cardPath)/attachments")
#expect(try card(in: fixture).attachments.isEmpty)
}
/// A listing that *cannot* be made degrades to `[]` here because a hand-editor left a
/// `attachments` **file** where the folder would be. Fail-fast is reserved for structure
/// (01-storage-format.md § Malformed input); a cosmetic field must never be why a board
/// refuses to open, and the file itself is preserved verbatim like any other stray.
@Test func anAttachmentsThatIsNotADirectoryDegradesToAnEmptyListing() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
try fixture.strayFile("\(cardPath)/attachments", contents: "not a folder")
let result = try BoardLoader.load(boardRoot: fixture.root)
#expect(result.model.lanes.first?.cards.first?.attachments.isEmpty == true)
#expect(result.warnings.isEmpty)
}
/// `BoardWriter.listAttachments` the card window sidebar's authoritative listing and
/// `Card.attachments` are **one enumeration**, so a sidebar and a face looking at the same
/// card can never disagree about its files or their order.
@Test func theSnapshotsListingAndTheWritersAreTheSameAnswer() throws {
let fixture = try BoardFixture()
defer { fixture.tearDown() }
let cardPath = try boardWithOneCard(fixture)
for name in ["shot 10.png", "shot 2.png", "shot.png", ".hidden"] {
try fixture.strayFile("\(cardPath)/attachments/\(name)")
}
try fixture.strayFile("\(cardPath)/attachments/sub/nested.txt")
let cardFolder = fixture.root.appendingPathComponent(cardPath, isDirectory: true)
#expect(try card(in: fixture).attachments == BoardWriter.listAttachments(ofCard: cardFolder))
}
}
// MARK: - ItemID value semantics (01-storage-format.md § Fractal layout Rules, "Identity
// comparison is UUID-value equality, never string equality")
+32
View File
@@ -117,6 +117,38 @@ struct FixtureRichBoardTests {
#expect(done.cards[0].title.value == "Ship v1")
}
/// `attachments/` is **flat** (01-storage-format.md § Attachments): the card's listing is its
/// top-level regular files and nothing else. This card's folder holds all four shapes on real
/// disk two ordinary files, a hidden one, and a subfolder with a file in it so the rule is
/// asserted against a filesystem rather than against a mock.
///
/// The excluded three are excluded for three different reasons and only one of them is stated
/// in the design doc: subfolders are "tolerated, preserved verbatim and not surfaced"; the
/// hidden file is the loader's uniform `.skipsHiddenFiles` stance (a `.DS_Store` is not
/// anyone's attachment); symlinks are the loader's never-resolve stance, covered in
/// `BoardLoaderTests` because git cannot carry that shape into a fixture reliably.
@Test func aCardListsOnlyTheTopLevelFilesOfItsAttachmentsFolder() throws {
let result = try loadFixture("Valid/rich-board.kanban")
let doing = try #require(result.model.lanes.first { $0.id.rawValue == RichBoard.laneDoing })
let taxonomy = try #require(doing.cards.first { $0.id.rawValue == RichBoard.cardTaxonomy })
#expect(taxonomy.attachments == ["notes.txt", "sketch.png"])
}
/// The overwhelmingly common shape: no `attachments/` folder at all. It reads as an empty
/// listing, never as a warning or a failure nothing has been attached yet is an ordinary
/// state, and it is what makes the face's paperclip indicator absent by default.
@Test func cardsWithoutAnAttachmentsFolderListNothing() throws {
let result = try loadFixture("Valid/rich-board.kanban")
let doing = try #require(result.model.lanes.first { $0.id.rawValue == RichBoard.laneDoing })
let second = try #require(doing.cards.first { $0.id.rawValue == RichBoard.cardSecond })
let done = try #require(result.model.lanes.first { $0.id.rawValue == RichBoard.laneDone })
#expect(second.attachments.isEmpty)
#expect(done.cards.map(\.attachments) == [[]])
#expect(result.warnings.isEmpty)
}
@Test func boardUnknownAndReservedKeysPreserveOrder() throws {
let result = try loadFixture("Valid/rich-board.kanban")
// schema-owned keys (schema, title, created, modified, modified-by, background, icon,
+164
View File
@@ -0,0 +1,164 @@
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")
}
}
}
+1
View File
@@ -13,6 +13,7 @@ Lanework is in early development. This list tracks what has actually shipped and
- **Live store** — every open board is one shared, watched, in-memory snapshot: an FSEvents folder watcher (debounced, `.git`-filtered, origin-reconciling) drives whole-tree reloads with a generation guard and single-flight coalescing; write brackets suppress self-echo; a file-identity-keyed store registry refcounts stores and watchers across windows and absorbs root renames via bookmark re-resolution (a vanished root locks the board and watches for its return); plus the board registry (recents, bookmarks, cached counts), the banner center's single precedence order, the dirty-buffer guard, and transient UI state.
- **Window architecture** — the three window types and their lifecycle: a welcome window (branding, failed-open reporting), one board window per root (per-board frame memory, repositioned onto a live screen), and at-most-one card window per card (last-used size, cascaded; follows its card across lanes; dismisses on delete, tombstone, or cross-board move). Closing a board window or quitting runs one strict close flush — card sessions end, pending work drains, the registry is stamped — before the store tears down; launch restores the boards whose open-now flags survived quit (or crash), a preference gating only whether the flags are consulted.
- **The board** — every lane always on screen, the window's width dividing across the lanes' width units with no horizontal scroll: cards flow into as many interior masonry columns as a lane is wide, a right-edge drag resizes between whole units by growing the *window* (snapping at the gap with release hysteresis, hard-stopping at the screen with rubber-band feedback), and ⌥⌘→/⌥⌘← re-divide the existing width instead. Lane chrome is a per-lane SF Symbol (unknown names fall back leniently), title or untitled placeholder, a card-count badge that counts exactly what's rendered, and a new-card button — the whole title bar doubling as the reorder drag surface, a plain click selecting the lane and movement carrying it above its siblings while they show the would-be order.
- **Card faces** — a card reads as a leading SF Symbol, its title (or a quiet untitled placeholder), and a quiet paperclip when it has attachments — title-only by design, no body excerpt. Colour is an edge accent rather than a fill: `background` paints a stripe down the card's left edge and `iconColor` tints the symbol, both written as a kebab-case palette name (12 icon tints, 12 backgrounds) or a `#RRGGBB[AA]` hex. Everything degrades rather than complains — an unreadable colour simply doesn't paint, and the value stays on disk exactly as written. Each card's snapshot carries its attachment names, listed flat and in Finder order (top-level files only; subfolders, hidden files, and symlinks are preserved but never surfaced).
- **Creating and renaming** — New Card (⌘N) files into the selected card's lane immediately after it, a selected lane's bottom, or the last-active lane, opening a focused pseudo-card that exists nowhere on disk until its title commits (Return commits and re-selects the lane, ⌘↩ also opens the card window, Escape or clicking away discards, and a failed create discards rather than waiting for a card that can't arrive). Inline rename — Return on a card, Board ▸ Rename for either kind — tracks its target by UUID, so a foreign move mid-edit is invisible and a tombstone or deletion discards the edit silently; committing empty removes the `title` key rather than writing a blank one. New Lane is ⇧⌘N. Every mutating command disables while an editor holds the keyboard and under the read-only lock.
## Development