From b4c90838b478bf81d706397c322df685b8cc0a51 Mon Sep 17 00:00:00 2001 From: rzen Date: Mon, 27 Jul 2026 13:48:50 -0400 Subject: [PATCH] Build card faces with edge-accent styling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Fixtures/README.md | 4 +- .../attachments/.hidden-note.txt | 1 + .../attachments/notes.txt | 1 + .../attachments/sub/nested.txt | 1 + Kanban/Storage/BoardLoader.swift | 64 +++++- Kanban/Storage/BoardModel.swift | 27 ++- Kanban/Storage/BoardWriter.swift | 25 +-- Kanban/UI/Board/LaneView.swift | 182 ++++++++++++++---- Kanban/UI/Palette.swift | 126 ++++++++++++ KanbanTests/BoardLoaderTests.swift | 133 ++++++++++++- KanbanTests/FixtureBoardTests.swift | 32 +++ KanbanTests/PaletteTests.swift | 164 ++++++++++++++++ README.md | 1 + 13 files changed, 692 insertions(+), 69 deletions(-) create mode 100644 Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/.hidden-note.txt create mode 100644 Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/notes.txt create mode 100644 Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/sub/nested.txt create mode 100644 Kanban/UI/Palette.swift create mode 100644 KanbanTests/PaletteTests.swift diff --git a/Fixtures/README.md b/Fixtures/README.md index 2a7fdc9..2e8e0fc 100644 --- a/Fixtures/README.md +++ b/Fixtures/README.md @@ -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. | diff --git a/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/.hidden-note.txt b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/.hidden-note.txt new file mode 100644 index 0000000..497eb9e --- /dev/null +++ b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/.hidden-note.txt @@ -0,0 +1 @@ +Hidden entries are never attachments (skipsHiddenFiles). diff --git a/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/notes.txt b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/notes.txt new file mode 100644 index 0000000..2392722 --- /dev/null +++ b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/notes.txt @@ -0,0 +1 @@ +Not a real .txt either — a stand-in attachment. diff --git a/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/sub/nested.txt b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/sub/nested.txt new file mode 100644 index 0000000..9965c5a --- /dev/null +++ b/Fixtures/Valid/rich-board.kanban/10000000-0000-4000-8000-000000000001/30000000-0000-4000-8000-000000000003/attachments/sub/nested.txt @@ -0,0 +1 @@ +A subfolder file: tolerated, preserved, never surfaced. diff --git a/Kanban/Storage/BoardLoader.swift b/Kanban/Storage/BoardLoader.swift index 8014402..0f8cfc9 100644 --- a/Kanban/Storage/BoardLoader.swift +++ b/Kanban/Storage/BoardLoader.swift @@ -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 `/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") diff --git a/Kanban/Storage/BoardModel.swift b/Kanban/Storage/BoardModel.swift index a055e3f..af1b7ae 100644 --- a/Kanban/Storage/BoardModel.swift +++ b/Kanban/Storage/BoardModel.swift @@ -151,9 +151,11 @@ public struct Lane: Identifiable, Sendable, Equatable { public var isDeleted: Bool { !deleted.isMissing } } -/// A card: `///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: `///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 diff --git a/Kanban/Storage/BoardWriter.swift b/Kanban/Storage/BoardWriter.swift index 444bf0f..658d34e 100644 --- a/Kanban/Storage/BoardWriter.swift +++ b/Kanban/Storage/BoardWriter.swift @@ -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 diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index 9f8f55d..b7b65ee 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -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) } diff --git a/Kanban/UI/Palette.swift b/Kanban/UI/Palette.swift new file mode 100644 index 0000000..e0f6aa1 --- /dev/null +++ b/Kanban/UI/Palette.swift @@ -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) -> 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 + ) + } +} diff --git a/KanbanTests/BoardLoaderTests.swift b/KanbanTests/BoardLoaderTests.swift index c64c438..f850607 100644 --- a/KanbanTests/BoardLoaderTests.swift +++ b/KanbanTests/BoardLoaderTests.swift @@ -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") diff --git a/KanbanTests/FixtureBoardTests.swift b/KanbanTests/FixtureBoardTests.swift index c6c1a22..2cd1520 100644 --- a/KanbanTests/FixtureBoardTests.swift +++ b/KanbanTests/FixtureBoardTests.swift @@ -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, diff --git a/KanbanTests/PaletteTests.swift b/KanbanTests/PaletteTests.swift new file mode 100644 index 0000000..973239b --- /dev/null +++ b/KanbanTests/PaletteTests.swift @@ -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.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") + } + } +} diff --git a/README.md b/README.md index e162cc5..07e1506 100644 --- a/README.md +++ b/README.md @@ -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