diff --git a/Kanban/LiveStore/EchoLedger.swift b/Kanban/LiveStore/EchoLedger.swift index b5a5622..fd0e023 100644 --- a/Kanban/LiveStore/EchoLedger.swift +++ b/Kanban/LiveStore/EchoLedger.swift @@ -433,7 +433,10 @@ public final class EchoLedger: Sendable { // here speaks `CommentPath` (LiveStore's comment-thread vocabulary), and its one caller is // the Mac store's comment reload. The phone's writers stamp comment receipts identically; // nothing on the phone retires them yet. - #if os(macOS) + // + // `STORAGE_ONLY` is that same gate one axis over — see the verdicts section below, which states + // it in full. + #if os(macOS) && !STORAGE_ONLY func vouchedComments(inCard cardFolder: URL, cardPath: String) -> Set { let root = Self.key(cardFolder) let paths = receiptPaths(under: root) @@ -554,7 +557,19 @@ extension EchoLedger.Receipt { // phone records receipts through the surface above (BoardWriter stamps identically on every // platform); its metadata-query observer has no verdict surface yet, and when it grows one this // gate is the seam it lands behind. -#if os(macOS) +// +// **`STORAGE_ONLY` is the same seam on a second axis** (added 2026-08-09 with the Quick Look preview +// extension, project.yml ▸ KanbanQuickLook): `os(macOS)` says which *platform* compiles this, and +// that flag says which *layer* does. A target that wants the storage engine and nothing above it — +// a preview extension, and any importer or tool that follows — compiles `Kanban/Storage` whole, +// because the directory is one unit in practice (`FrontmatterDocument` → `IntegrityRules` → +// `BoardLoader`/`BoardWriter` → this file's recording surface). What it cannot compile is *this* +// section, which reaches back up into the live store; without the flag the whole of `Kanban/LiveStore` +// would have to come with it, which is the tail wagging a read-only preview. +// +// Nothing in the app or the phone defines it, so both are unchanged by construction: the flag only +// ever *subtracts*, and only from a target that has no caller for any of it. +#if os(macOS) && !STORAGE_ONLY /// One landing reload's provenance answers, in the two shapes the announcer needs. public struct EchoVerdicts: Sendable, Equatable { diff --git a/Kanban/Storage/BoardOutline.swift b/Kanban/Storage/BoardOutline.swift new file mode 100644 index 0000000..e84b05b --- /dev/null +++ b/Kanban/Storage/BoardOutline.swift @@ -0,0 +1,322 @@ +import Foundation + +/// **Everything a Quick Look preview knows about a board** — its name, its tint, and each lane's +/// first few card titles — read straight off the folder tree with no store, no watcher and no +/// window behind it. +/// +/// ### Why this is not `BoardLoader.load` +/// +/// The snapshot the app opens a board with is the wrong shape for a preview in three ways, and each +/// one is a reason rather than a preference: +/// +/// - **It fails.** `BoardLoader` collects fail-fast defects and throws a `BoardLoadFailure` for a +/// missing root `index.md`, an unreadable `schema`, a lane whose YAML will not parse +/// (01-storage-format.md § Malformed input). That is exactly right for opening a board — the user +/// is about to edit it — and exactly wrong for pressing Space in Finder, where the honest answer to +/// a half-broken board is *the part that reads*. Nothing in this file throws: every failure +/// degrades to the empty answer for the thing that failed and the walk carries on. +/// - **It is unbounded.** The loader visits every card, and inside each one it lists `attachments/` +/// and counts `comments/` (`BoardModel.Card`) — three directory reads per card, plus a parse, on +/// a board that may hold thousands. A preview has a few hundred milliseconds and no second +/// chance, so this walk is *capped at every level* (`BoardOutlineLimits`) and opens no file it +/// does not put on screen. +/// - **It carries what a preview cannot use.** Trash, tombstone migration, the identity dedupe, the +/// parse memo, the integrity defect stream, the coerce-tier trace — none of it renders, and all of +/// it costs. +/// +/// What this does reuse is everything that decides *what the answer is*: `FrontmatterDocument` for +/// the parse and every field reading, `IntegrityRules.isIdentityShaped` for what counts as a lane or +/// a card, `BoardLoader.directoryCandidates` for the stray tolerance (hidden entries and symlinks +/// out, folder-name order in), and `Ranks` for display order. A second walker that re-derived any of +/// those would be a second storage format; this one only decides *how far to look*. +/// +/// ### The name +/// +/// "Outline", not "summary": `KanbanMobile/Cloud/BoardSummary.swift` already owns that word for the +/// phone's board *list* row — a name, a size, a download state, nothing about the contents — and the +/// phone compiles this directory into its own module, so the two would collide outright. The words +/// are worth keeping apart anyway: that one summarizes a board's *file*, and this one outlines its +/// *contents*. +public struct BoardOutline: Sendable, Equatable { + + /// The board's name — its `title`, falling back to the folder name with `.kanban` stripped + /// (01-storage-format.md § Board naming). Non-optional, deliberately: this is + /// `AppModel.displayName(of:)`'s rule read one layer down, and "Untitled" appears nowhere on a + /// board because a board always has a folder (BoardInfoPopover ▸ Rename — "boards do not have + /// it"). Contrast `LaneOutline.title`. + public let title: String + + /// The `background` mapping's colour as written — a palette name or a `#RRGGBB[AA]` hex — or + /// `nil` for a board that carries none. + /// + /// **The value, not a colour**: resolving it is the renderer's job through `Palette`, exactly as + /// it is in the app, so an unrecognized value degrades to "no tint" at the one place that knows + /// what no tint looks like (Palette.swift — "Lenient, never an error"). + public let background: String? + + /// The board's `icon` (an SF Symbol name) and `iconColor`, both as written and both lenient in + /// the same way `background` is: a name the running system cannot draw is the renderer's problem + /// to degrade, not this walk's to validate. + public let icon: String? + public let iconColor: String? + + /// The lanes the preview shows, in display order — **a prefix**, capped by + /// `BoardOutlineLimits.lanes`. `hiddenLaneCount` is the rest. + public let lanes: [LaneOutline] + + /// Every lane the walk found, shown or not — the number the preview's header line states. + /// + /// `laneCountIsCapped` marks the walk that stopped counting (`BoardOutlineLimits.laneScan`), so + /// a renderer can say "100+" rather than a number it would be lying about. On every board that + /// has ever existed this is `false`; it is here because a preview must have a bounded worst case + /// even on a folder nobody meant to be a board. + public let laneCount: Int + public let laneCountIsCapped: Bool + + /// The lanes counted but not shown — the "+N more lanes" the preview ends on, and `0` on a board + /// whose lanes all fit. + public var hiddenLaneCount: Int { max(0, laneCount - lanes.count) } + + public init( + title: String, + background: String? = nil, + icon: String? = nil, + iconColor: String? = nil, + lanes: [LaneOutline] = [], + laneCount: Int = 0, + laneCountIsCapped: Bool = false + ) { + self.title = title + self.background = background + self.icon = icon + self.iconColor = iconColor + self.lanes = lanes + self.laneCount = laneCount + self.laneCountIsCapped = laneCountIsCapped + } +} + +/// One lane in a board summary: a title, a colour, a count, and the first few card titles. +public struct LaneOutline: Sendable, Equatable { + + /// The lane's `title` as written, or `nil` where it carries none — **"Untitled" is a rendering, + /// never a value** (03-board-ui.md § Card face; the same rule `Lane`/`Card` follow throughout the + /// app), so the placeholder is applied where the text is drawn and never stored here. An empty + /// or whitespace-only title reads as `nil` for the same reason a blank lane header does. + public let title: String? + + /// The lane's `background` colour as written — see `BoardOutline.background`; a lane's is an edge + /// accent rather than a backdrop (03-board-ui.md § Styling ▸ Capabilities). + public let background: String? + + /// The first few cards' titles, in display order, each `nil` where the card carries no title. + /// Capped by `BoardOutlineLimits.cardTitles`. + /// + /// **In display order over the *scanned* cards**, which is every card in the overwhelming case + /// and the first `BoardOutlineLimits.cardScan` of them otherwise — see that field for the cost + /// the cap accepts. + public let cardTitles: [String?] + + /// How many cards the lane holds — a `readdir` plus one `stat` per child, never a parse, which is + /// `BoardLoader.commentCount(in:)`'s own arrangement one level up: a card is an identity-shaped + /// child carrying its own `index.md`, and whether that file *parses* costs a read this count does + /// not pay. + public let cardCount: Int + + /// Whether the count stopped at `BoardOutlineLimits.cardCount` — a lane holding more cards than + /// the walk was willing to stat, which a renderer states as "2000+" rather than as a number. + public let cardCountIsCapped: Bool + + /// The cards counted but not titled — the "+N more" under a lane's last shown title. + public var hiddenCardCount: Int { max(0, cardCount - cardTitles.count) } + + public init( + title: String? = nil, + background: String? = nil, + cardTitles: [String?] = [], + cardCount: Int = 0, + cardCountIsCapped: Bool = false + ) { + self.title = title + self.background = background + self.cardTitles = cardTitles + self.cardCount = cardCount + self.cardCountIsCapped = cardCountIsCapped + } +} + +/// **How far the summary walk looks** — the whole of what makes a preview bounded. +/// +/// Quick Look gives an extension a short budget and no way to say "still working"; a board is a +/// folder tree of unknown size that a user can point Finder at. So every level of the walk has a +/// ceiling, and the ceilings are here rather than scattered through the walk so the answer to "what +/// does a preview cost, worst case?" is one struct: at most `laneScan` lane `index.md` parses, plus +/// per shown lane at most `cardCount` stats and `cardScan` card `index.md` parses. +/// +/// The defaults are `preview`'s. Every one is generous enough that a real board is summarized +/// exactly — the caps exist for the pathological folder, not for the ordinary one. +public struct BoardOutlineLimits: Sendable, Equatable { + + /// Lanes rendered, in display order. The rest become `BoardOutline.hiddenLaneCount`. + public var lanes: Int + + /// Root children the walk will consider a lane at all. Past this the count itself stops + /// (`BoardOutline.laneCountIsCapped`) — it is the one cap that bounds the lane parse, since + /// display order cannot be known without reading every sibling's `order`. + public var laneScan: Int + + /// Card titles shown per lane. The rest become `LaneOutline.hiddenCardCount`. + public var cardTitles: Int + + /// Card `index.md` files parsed per lane, taken in folder-name order. + /// + /// **The one cap with a visible cost**, stated plainly: a card's rank lives in its own file, so + /// the true first-`cardTitles` of a lane cannot be known without reading all of them. A lane + /// holding more cards than this shows the top of the *scanned* subset instead — its **count + /// stays exact**, and the titles are still cards in that lane, just not provably the topmost. + /// Set high enough that no lane a person has ever built reaches it. + public var cardScan: Int + + /// Children stat'd per lane while counting. Past this the count stops + /// (`LaneOutline.cardCountIsCapped`). + public var cardCount: Int + + public init(lanes: Int, laneScan: Int, cardTitles: Int, cardScan: Int, cardCount: Int) { + self.lanes = lanes + self.laneScan = laneScan + self.cardTitles = cardTitles + self.cardScan = cardScan + self.cardCount = cardCount + } + + /// The Quick Look preview's own ceilings — the only set anything but a test uses. + public static let preview = BoardOutlineLimits( + lanes: 12, laneScan: 100, cardTitles: 6, cardScan: 200, cardCount: 2000 + ) +} + +// MARK: - The walk + +extension BoardOutline { + + /// Summarizes the board rooted at `boardRoot` — **total, and never throwing**. + /// + /// A preview has no error surface: there is no alert to raise, no Repair sheet to offer and no + /// second attempt, so every failure this walk can meet degrades in place. A root that is not a + /// directory, an `index.md` that is missing or unparseable or not UTF-8, a lane folder that + /// vanishes mid-walk, a permissions race on a directory listing — each yields the empty answer + /// for that one thing and nothing else changes. The floor is a summary carrying the folder's own + /// name and no lanes, which is still a truthful preview of a folder that is not a board. + /// + /// A lane or card whose `index.md` will not parse is **kept, not skipped**: it reads as untitled + /// and order-less (append-at-end, `Ranks.resolvedOrders`), so a board with one broken file + /// previews with one blank title rather than with a lane silently missing and a count that + /// disagrees with Finder. + public static func read(boardRoot: URL, limits: BoardOutlineLimits = .preview) -> BoardOutline { + let document = parsedIndex(in: boardRoot) + + // The lane ceiling is applied to the *name-shaped* children, before the per-child `stat` that + // asks whether each holds an `index.md` — `readLane`'s ordering, and for its reason. + let identityShaped = directoryCandidates(in: boardRoot) + .filter { IntegrityRules.isIdentityShaped($0.lastPathComponent) } + let considered = identityShaped.prefix(max(0, limits.laneScan)) + let candidates = considered.filter(hasIndex) + + let walked = candidates.map { (url: $0, document: parsedIndex(in: $0)) } + let orders = Ranks.resolvedOrders( + of: walked, + stored: { $0.document?.order.value }, + name: { $0.url.lastPathComponent } + ) + let ranked = Array(zip(walked, orders)) + let ordered = Ranks.sortedForDisplay( + ranked, + order: { $0.1 }, + name: { $0.0.url.lastPathComponent } + ) + + let shown = ordered.prefix(max(0, limits.lanes)) + return BoardOutline( + title: text(document?.title) ?? boardRoot.deletingPathExtension().lastPathComponent, + background: text(document?.background), + icon: text(document?.icon), + iconColor: text(document?.iconColor), + lanes: shown.map { readLane(at: $0.0.url, document: $0.0.document, limits: limits) }, + laneCount: ordered.count, + laneCountIsCapped: identityShaped.count > considered.count + ) + } + + /// One lane: its own fields off an already-parsed `index.md`, then its cards — counted by + /// listing, titled by parsing, and both capped. + private static func readLane( + at laneURL: URL, + document: FrontmatterDocument?, + limits: BoardOutlineLimits + ) -> LaneOutline { + // The count's two halves, in this order on purpose: the name-shape filter is a string test + // and free, so the ceiling is applied to *it* and the per-child `stat` below never runs more + // than `limits.cardCount` times. + let identityShaped = directoryCandidates(in: laneURL) + .filter { IntegrityRules.isIdentityShaped($0.lastPathComponent) } + let considered = identityShaped.prefix(max(0, limits.cardCount)) + let cards = considered.filter(hasIndex) + + let scanned = cards.prefix(max(0, limits.cardScan)) + let walked = scanned.map { (url: $0, document: parsedIndex(in: $0)) } + let orders = Ranks.resolvedOrders( + of: walked, + stored: { $0.document?.order.value }, + name: { $0.url.lastPathComponent } + ) + let ordered = Ranks.sortedForDisplay( + Array(zip(walked, orders)), + order: { $0.1 }, + name: { $0.0.url.lastPathComponent } + ) + + return LaneOutline( + title: text(document?.title), + background: text(document?.background), + cardTitles: ordered.prefix(max(0, limits.cardTitles)).map { text($0.0.document?.title) }, + cardCount: cards.count, + cardCountIsCapped: identityShaped.count > considered.count + ) + } + + // MARK: - Filesystem helpers + + /// `BoardLoader.directoryCandidates(in:)` with its typed throw flattened to the empty answer — + /// the function never actually throws (an unlistable folder already degrades to `[]` inside it), + /// and this walk has nowhere to put an error even if it did. + private static func directoryCandidates(in folder: URL) -> [URL] { + (try? BoardLoader.directoryCandidates(in: folder)) ?? [] + } + + private static func hasIndex(_ folder: URL) -> Bool { + FileManager.default.fileExists( + atPath: folder.appendingPathComponent(IntegrityRules.indexFileName).path + ) + } + + /// A folder's `index.md`, parsed — `nil` for every way that can fail: no file, unreadable bytes, + /// not UTF-8, unparseable YAML. The strict UTF-8 decode is `BoardLoader.parseDocument`'s, so a + /// BOM'd file reads here exactly as it loads there: as nothing. + private static func parsedIndex(in folder: URL) -> FrontmatterDocument? { + let url = folder.appendingPathComponent(IntegrityRules.indexFileName) + guard let data = try? Data(contentsOf: url), + let text = String(validating: data, as: UTF8.self) + else { return nil } + return try? FrontmatterDocument.parse(text) + } + + /// A lenient string field's text, with **blank read as absent** — a title of `""` or of spaces is + /// a key that says nothing, and the summary's whole vocabulary for "says nothing" is `nil` + /// (`AppModel.displayName(of:)` applies the same `!title.isEmpty` test to a board's own name). + /// A missing key and a malformed one collapse to `nil` here too, exactly as they do at every + /// other lenient call site in the app. + private static func text(_ field: FieldValue?) -> String? { + guard let value = field?.value else { return nil } + return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value + } +} diff --git a/KanbanQuickLook/BoardPreviewPage.swift b/KanbanQuickLook/BoardPreviewPage.swift new file mode 100644 index 0000000..6693d9f --- /dev/null +++ b/KanbanQuickLook/BoardPreviewPage.swift @@ -0,0 +1,328 @@ +import AppKit +import Foundation +import Quartz +import UniformTypeIdentifiers + +/// **The preview's whole appearance** — a `BoardOutline` turned into one self-contained HTML +/// document plus the images it references. +/// +/// ### Why the styling values are resolved here and not in the walk +/// +/// `BoardOutline` carries `background`, `icon` and `iconColor` **as written** — a palette name, a +/// hex, an SF Symbol name — exactly as `BoardModel` does, because "an unrecognized value renders as +/// the default and the bytes stay as written" is a *rendering* rule and this file is the renderer +/// (Palette.swift ▸ "Lenient, never an error"; ItemSymbol.swift ▸ the same for symbols). Resolution +/// goes through `Palette` itself rather than through a second copy of the twelve-and-twelve table, +/// so a board tinted `smokey-ocean` is the same colour in the preview as it is in the app. +/// +/// ### Why the board tint is a wash and not a band +/// +/// A board's colour is chosen to sit *behind a whole window* of app chrome, where the app picks +/// readable ink for it against the window backdrop (`ContrastMath` ▸ `BoardTextInk`). A preview has +/// no such machinery and should not grow one for a header strip, so the tint is applied at low alpha +/// under the title and at full strength only as a lane's edge accent — narrow marks that carry no +/// text. Contrast is then never in question, in either appearance, for any of the 16.7 million +/// values a hand-written hex can be. +enum BoardPreviewPage { + + /// The size Quick Look draws its loading state at, and its opening guess at the panel. Only a + /// hint: HTML has no intrinsic size, so the panel is the user's to resize and the page reflows. + static let contentSizeHint = CGSize(width: 820, height: 560) + + /// The board's default symbol when it carries no `icon` — `ItemSymbol.board`'s value + /// (03-board-ui.md § Styling ▸ Capabilities: "`icon`: SF Symbol per item with per-level + /// defaults"). + /// + /// Restated rather than shared: `ItemSymbol` reaches `StyleLevel` for its per-level lookup, which + /// reaches the live store, which has no business inside a preview extension. One string is the + /// cheaper coupling, and it is the board's default — the only one of the three this file can ever + /// need. + private static let defaultSymbol = "rectangle.split.3x1" + + /// The mark's two bakings. A PNG has one colour and a preview has two appearances, so the page + /// carries both and lets CSS choose — see `attachments(for:)`. + private static let lightMarkID = "board-mark-light" + private static let darkMarkID = "board-mark-dark" + + // MARK: - The page + + static func html(for outline: BoardOutline) -> Data { + var out = "\n\n\n\n" + out += "\n" + out += "\(escape(outline.title))\n" + out += "\n\n\n" + out += "
\n" + out += header(for: outline) + out += lanes(of: outline) + out += "
\n\n\n" + return Data(out.utf8) + } + + private static func header(for outline: BoardOutline) -> String { + var out = "
\n" + out += "\"\"" + out += "\"\"\n" + out += "
\n" + out += "

\(escape(outline.title))

\n" + out += "

\(escape(laneCountPhrase(for: outline)))

\n" + out += "
\n
\n" + return out + } + + /// "3 lanes", "1 lane", "No lanes yet" — and "100+ lanes" for the board whose count the walk + /// stopped taking (`BoardOutline.laneCountIsCapped`), because a number that is not the number is + /// worse than an honest floor. + private static func laneCountPhrase(for outline: BoardOutline) -> String { + guard outline.laneCount > 0 else { return "No lanes yet" } + let count = outline.laneCountIsCapped ? "\(outline.laneCount)+" : "\(outline.laneCount)" + return "\(count) \(outline.laneCount == 1 && !outline.laneCountIsCapped ? "lane" : "lanes")" + } + + private static func lanes(of outline: BoardOutline) -> String { + // A board with nothing in it, and a folder that is not a board at all, reach here the same + // way and say so once — the header's "No lanes yet" is the count, this is the invitation. + guard outline.laneCount > 0 else { + return "

This board has no lanes yet.

\n" + } + + var out = "
\n" + for lane in outline.lanes { + let accent = cssColor(lane.background).map { " style=\"--accent: \($0)\"" } ?? "" + out += "
\n" + out += "

\(escape(lane.title ?? "Untitled"))" + out += "\(escape(cardCountPhrase(for: lane)))

\n" + if lane.cardTitles.isEmpty { + out += "

Empty

\n" + } else { + out += "
    \n" + for title in lane.cardTitles { + out += "
  • \(escape(title ?? "Untitled"))
  • \n" + } + out += "
\n" + } + if lane.hiddenCardCount > 0 { + out += "

+\(lane.hiddenCardCount) more

\n" + } + out += "
\n" + } + out += "
\n" + + if outline.hiddenLaneCount > 0 { + let suffix = outline.laneCountIsCapped ? "+" : "" + out += "

+\(outline.hiddenLaneCount)\(suffix) more lanes

\n" + } + return out + } + + /// A lane's badge: its exact card count, or the floor the count stopped at + /// (`LaneOutline.cardCountIsCapped`). + private static func cardCountPhrase(for lane: LaneOutline) -> String { + lane.cardCountIsCapped ? "\(lane.cardCount)+" : "\(lane.cardCount)" + } + + // MARK: - Style + + private static func stylesheet(for outline: BoardOutline) -> String { + let wash = cssColor(outline.background, alpha: 0.16) ?? "transparent" + let rule = cssColor(outline.background, alpha: 0.55) ?? "var(--hairline)" + return """ + :root { + --ink: #1d1d1f; + --ink-quiet: #6e6e73; + --page: #ffffff; + --plate: #f5f5f7; + --hairline: rgba(0, 0, 0, 0.12); + --wash: \(wash); + --rule: \(rule); + --accent: var(--hairline); + } + @media (prefers-color-scheme: dark) { + :root { + --ink: #f5f5f7; + --ink-quiet: #98989d; + --page: #1e1e1e; + --plate: #2a2a2c; + --hairline: rgba(255, 255, 255, 0.16); + } + } + * { box-sizing: border-box; } + body { + margin: 0; + background: var(--page); + color: var(--ink); + font: 13px/1.45 -apple-system, "SF Pro Text", "Helvetica Neue", sans-serif; + -webkit-font-smoothing: antialiased; + } + main { padding: 20px 22px 26px; } + header.board { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 14px; + border-radius: 10px; + background: var(--wash); + border-bottom: 2px solid var(--rule); + /* The wash and the rule are both the board's own colour, so a board tinted the same shade + as the page it is previewed on paints nothing at all — a `#1E1E1E` board in the dark + appearance. This hairline is what keeps the block a block regardless: the tint is the + board's, the edge is the page's. */ + box-shadow: inset 0 0 0 1px var(--hairline); + } + .mark { width: 34px; height: 34px; flex: none; object-fit: contain; } + .light-only { display: block; } + .dark-only { display: none; } + @media (prefers-color-scheme: dark) { + .light-only { display: none; } + .dark-only { display: block; } + } + .board-text { min-width: 0; } + h1 { + margin: 0; + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; + overflow-wrap: anywhere; + } + .meta { margin: 2px 0 0; color: var(--ink-quiet); font-size: 12px; } + .lanes { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 18px; + align-items: flex-start; + } + .lane { + flex: 1 1 190px; + min-width: 170px; + max-width: 300px; + padding: 10px 12px 10px 13px; + border-radius: 8px; + background: var(--plate); + border-left: 3px solid var(--accent); + } + .lane h2 { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + margin: 0 0 8px; + font-size: 13px; + font-weight: 600; + } + .lane-title { overflow-wrap: anywhere; } + .count { + flex: none; + color: var(--ink-quiet); + font-variant-numeric: tabular-nums; + font-weight: 400; + } + .lane ul { margin: 0; padding: 0; list-style: none; } + .lane li { + padding: 4px 0; + border-top: 1px solid var(--hairline); + overflow-wrap: anywhere; + } + .lane li:first-child { border-top: none; padding-top: 0; } + .more, .empty-lane { margin: 6px 0 0; color: var(--ink-quiet); font-size: 12px; } + .more-lanes, .empty { margin: 14px 0 0; color: var(--ink-quiet); font-size: 12px; } + """ + } + + // MARK: - Colour + + /// A stored styling value as a CSS colour — `Palette`'s resolution (a palette name or a + /// `#RRGGBB[AA]` hex), re-emitted as `rgba()` so the value's own alpha and the caller's can be + /// combined. `nil` for every shape `Palette` declines to read, which every call site treats as + /// "there is no colour" rather than as an error. + private static func cssColor(_ value: String?, alpha: Double = 1) -> String? { + guard let value, + let color = Palette.nsColor(for: value)?.usingColorSpace(.sRGB) + else { return nil } + let channel = { (component: CGFloat) in Int((component * 255).rounded()) } + let combined = Double(color.alphaComponent) * alpha + return "rgba(\(channel(color.redComponent)), \(channel(color.greenComponent)), " + + "\(channel(color.blueComponent)), \(String(format: "%.3f", combined)))" + } + + // MARK: - The mark + + /// The board's symbol, baked twice: once in the light appearance's ink and once in the dark + /// one's, so a page rendered in either reads correctly. A board that names its own `iconColor` + /// gets that colour in both — a chosen tint is not an appearance-dependent value. + /// + /// Both entries are always present, even when the symbol will not resolve: an attachment + /// dictionary missing a `cid:` the page references is a broken-image glyph, and this way a bad + /// `icon:` degrades to the *default* symbol exactly as it does in the app. + static func attachments(for outline: BoardOutline) -> [String: QLPreviewReplyAttachment] { + let symbol = outline.icon.flatMap(resolvedSymbol) ?? defaultSymbol + let chosen = outline.iconColor.flatMap(Palette.nsColor(for:)) + var attachments: [String: QLPreviewReplyAttachment] = [:] + let inks: [(id: String, fallback: NSColor)] = [ + (lightMarkID, NSColor(srgbRed: 0.11, green: 0.11, blue: 0.12, alpha: 1)), + (darkMarkID, NSColor(srgbRed: 0.96, green: 0.96, blue: 0.97, alpha: 1)), + ] + for ink in inks { + guard let png = markPNG(symbol: symbol, tint: chosen ?? ink.fallback) else { continue } + attachments[ink.id] = QLPreviewReplyAttachment(data: png, contentType: .png) + } + return attachments + } + + /// `name` if the running system can draw it as an SF Symbol — `ItemSymbol.exists(_:)`'s rule and + /// its reasoning ("`NSImage(systemSymbolName:)` is the only honest test"). + private static func resolvedSymbol(_ name: String) -> String? { + guard !name.isEmpty, NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil + else { return nil } + return name + } + + /// One symbol as tinted PNG bytes. + /// + /// Drawn at twice the size the page displays it at (34 CSS pixels), which is the whole reason for + /// the point size below: a PNG has no vector fallback, and a Retina panel would show a 34-pixel + /// image soft. `nil` for a symbol the system declines to draw or an image that will not encode — + /// the page then simply shows no mark, which is `Palette`'s "there is no colour, so show none" + /// one medium over. + private static func markPNG(symbol: String, tint: NSColor) -> Data? { + let configuration = NSImage.SymbolConfiguration(pointSize: 68, weight: .regular) + guard let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)? + .withSymbolConfiguration(configuration) + else { return nil } + + // `sourceAtop` over the drawn glyph rather than a palette configuration: it flattens a + // multicolour symbol to the one tint, which is what the app's own `foregroundStyle` does to + // it anyway, and it cannot come out black the way a template image can. + let tinted = NSImage(size: image.size, flipped: false) { rect in + image.draw(in: rect) + tint.setFill() + rect.fill(using: .sourceAtop) + return true + } + + guard let tiff = tinted.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff) + else { return nil } + return bitmap.representation(using: .png, properties: [:]) + } + + // MARK: - Escaping + + /// Every character that could end an attribute, open a tag or start an entity — the page is + /// assembled from board text that is entirely the user's, so nothing reaches the document without + /// passing through here. + private static func escape(_ text: String) -> String { + var escaped = "" + escaped.reserveCapacity(text.count) + for character in text { + switch character { + case "&": escaped += "&" + case "<": escaped += "<" + case ">": escaped += ">" + case "\"": escaped += """ + case "'": escaped += "'" + default: escaped.append(character) + } + } + return escaped + } +} diff --git a/KanbanQuickLook/BoardPreviewProvider.swift b/KanbanQuickLook/BoardPreviewProvider.swift new file mode 100644 index 0000000..c3802fc --- /dev/null +++ b/KanbanQuickLook/BoardPreviewProvider.swift @@ -0,0 +1,58 @@ +import Foundation +import Quartz +import UniformTypeIdentifiers + +/// **Space in Finder, on a `.kanban` package** — the whole of this extension's job. +/// +/// A board is a folder, and a folder previews as a folder: an icon and nothing else. This gives it a +/// document's preview instead — its name, its tint, its lanes in order, each lane's card count and +/// its first few card titles — built from `BoardOutline.read`, the capped read-only walk that lives +/// beside the storage format it reads (Kanban/Storage/BoardOutline.swift). +/// +/// ### Data-based, not view-based +/// +/// `QLPreviewProvider` is the data-based half of the Quick Look preview API: the extension answers +/// with *content* (`QLPreviewReply`) and Quick Look owns the window. The view-based half +/// (`QLPreviewingController` on an `NSViewController`) would put a SwiftUI hierarchy on screen, and +/// this preview has no use for one — it is static by design (v1: no interactivity, no attachment or +/// image loading), so a view controller would be a lifecycle to keep honest in exchange for nothing. +/// +/// The reply is **HTML**, which is the one supported data type that reflows: a Quick Look panel is +/// resized by the user and a board summary is a wrapping row of columns, so a fixed `contentSize` +/// drawing block or a rendered image would be the wrong shape the moment the panel is not the size +/// it was baked at. HTML also gets vector text, selectable text, its own scrolling, and light/dark +/// through `prefers-color-scheme` — all of which a `CGContext` reply would have to reinvent. +/// +/// ### Where the work happens +/// +/// Everything is inside the reply's data-creation block, which is where Apple's own documentation +/// puts it ("Heavy lifting should be done inside of the dataCreationBlock instead of when creating +/// the QLPreviewReply"): `providePreview` returns immediately with a size hint, Quick Look draws its +/// loading state at the right size, and the walk runs while it does. +/// +/// ### Reading the board +/// +/// The extension is sandboxed with read access to the URL it was handed and nothing else, which is +/// exactly what the walk needs — it opens `index.md` files under `request.fileURL` and never looks +/// outside the package. `BoardOutline.read` does not throw: a folder that is not a board, a board +/// with a broken `index.md`, a permissions race mid-walk all yield an outline of whatever *did* read, +/// so this method has no error path of its own and Quick Look never sees a failed preview where it +/// could have shown a name. +final class BoardPreviewProvider: QLPreviewProvider, QLPreviewingController { + + func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply { + let boardRoot = request.fileURL + return QLPreviewReply( + dataOfContentType: .html, + contentSize: BoardPreviewPage.contentSizeHint + ) { reply in + let outline = BoardOutline.read(boardRoot: boardRoot) + reply.stringEncoding = .utf8 + // The panel's own title bar. Left empty, Quick Look uses the file name — which is the + // folder name, and therefore *wrong* for every board whose `title:` differs from it. + reply.title = outline.title + reply.attachments = BoardPreviewPage.attachments(for: outline) + return BoardPreviewPage.html(for: outline) + } + } +} diff --git a/KanbanQuickLook/Info.plist b/KanbanQuickLook/Info.plist new file mode 100644 index 0000000..5e6b62f --- /dev/null +++ b/KanbanQuickLook/Info.plist @@ -0,0 +1,60 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Lanework Board Preview + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Lanework Board Preview + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + 1 + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + © 2026 rzen + + NSExtension + + NSExtensionPointIdentifier + com.apple.quicklook.preview + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).BoardPreviewProvider + NSExtensionAttributes + + QLSupportedContentTypes + + dev.rzen.indie.kanban-board + + QLSupportsSearchableItems + + QLIsDataBasedPreview + + + + + diff --git a/KanbanQuickLook/KanbanQuickLook.entitlements b/KanbanQuickLook/KanbanQuickLook.entitlements new file mode 100644 index 0000000..8bbcf1f --- /dev/null +++ b/KanbanQuickLook/KanbanQuickLook.entitlements @@ -0,0 +1,23 @@ + + + + + com.apple.security.app-sandbox + + + com.apple.security.files.user-selected.read-only + + + diff --git a/KanbanTests/BoardOutlineTests.swift b/KanbanTests/BoardOutlineTests.swift new file mode 100644 index 0000000..d950466 --- /dev/null +++ b/KanbanTests/BoardOutlineTests.swift @@ -0,0 +1,386 @@ +import Foundation +import Testing +@testable import Kanban + +// The Quick Look preview's reading of a board (`BoardOutline`, Kanban/Storage/BoardOutline.swift): +// what a `.kanban` package says about itself when Space is pressed on it in Finder. The extension +// shell around it — `QLPreviewProvider`, the HTML page, the symbol PNGs — is untested by design and +// by nature: it is a bundle the system loads out of process, and everything in it that could be +// wrong about a *board* is decided here. +// +// The walk is deliberately total (it never throws), so these tests assert the degrade as hard as they +// assert the happy path: a folder that is not a board, a lane whose YAML is broken, a card with no +// title, and every cap. + +// MARK: - Fixture builders + +/// A synthetic board tree under a temp directory — `BoardLoaderTests`' builder, one suite over, +/// carrying only the two shapes this walk can see (an `index.md`, and a folder without one). +private struct OutlineFixture { + let root: URL + + init(named name: String = "Board.kanban") throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("BoardOutlineTests-\(UUID().uuidString)", isDirectory: true) + .appendingPathComponent(name, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + func tearDown() { + try? FileManager.default.removeItem(at: root.deletingLastPathComponent()) + } + + /// Writes `index.md` at `relativePath` (`""` for the board root), with `frontmatter` between the + /// delimiters. `frontmatter` must end in a newline, exactly as the loader's own builder requires. + @discardableResult + func index(_ relativePath: String, _ frontmatter: String, body: String = "") throws -> URL { + let folder = relativePath.isEmpty + ? root + : root.appendingPathComponent(relativePath, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + try "---\n\(frontmatter)---\n\(body)" + .write(to: folder.appendingPathComponent("index.md"), atomically: true, encoding: .utf8) + return folder + } + + /// A folder with no `index.md` — the interrupted-create shape, which is not a lane and not a card. + @discardableResult + func emptyFolder(_ relativePath: String) throws -> URL { + let folder = root.appendingPathComponent(relativePath, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + return folder + } + + func file(_ relativePath: String, contents: String) throws { + let url = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try contents.write(to: url, atomically: true, encoding: .utf8) + } +} + +/// A UUID-shaped folder name whose *lexicographic* order is the test's to choose — the walk's +/// tie-break is the folder name, so a test about ordering needs names it controls. +private func identity(_ nibble: String) -> String { + "\(nibble)0000000-0000-4000-8000-000000000000" +} + +/// The `Fixtures/` folder reference in the test bundle — `FixtureBoardTests`' resolution, restated +/// here because that file keeps its own private (there is no `Bundle.module` in an xcodeproj target). +private final class OutlineFixtureBundleAnchor {} + +private func fixtureBoard(_ relativePath: String) -> URL { + guard let resources = Bundle(for: OutlineFixtureBundleAnchor.self).resourceURL else { + fatalError("test bundle has no resourceURL") + } + return resources + .appendingPathComponent("Fixtures", isDirectory: true) + .appendingPathComponent(relativePath, isDirectory: true) +} + +// MARK: - The golden board + +/// `Fixtures/Valid/rich-board.kanban` is the disk-backed case: a real tree, hand-authored, with two +/// lanes, three cards, styling at every level and unknown keys throughout. If the preview reads this +/// board correctly it reads the format correctly. +struct BoardOutlineFixtureTests { + + @Test func readsTheRichBoardsNameStylingAndLanes() { + let summary = BoardOutline.read(boardRoot: fixtureBoard("Valid/rich-board.kanban")) + + #expect(summary.title == "Rich Demo Board") + #expect(summary.background == "#1E1E1E") + #expect(summary.icon == "rectangle.stack.fill") + #expect(summary.iconColor == "purple") + #expect(summary.laneCount == 2) + #expect(summary.laneCountIsCapped == false) + #expect(summary.hiddenLaneCount == 0) + + // Lane display order is `order` ascending — Doing (1024) then Done (2048). + #expect(summary.lanes.map(\.title) == ["Doing", "Done"]) + #expect(summary.lanes.map(\.cardCount) == [2, 1]) + #expect(summary.lanes.map(\.cardCountIsCapped) == [false, false]) + #expect(summary.lanes.map(\.background) == ["#3478F6", "green"]) + + // Cards in display order within each lane, and `attachments/`/`comments/` beside one of them + // are not cards: they are not identity-shaped, so the count stays at two. + #expect(summary.lanes[0].cardTitles == [ + "Design the fixture taxonomy", "Wire up the loader's stray tolerance", + ]) + #expect(summary.lanes[1].cardTitles == ["Ship v1"]) + #expect(summary.lanes.map(\.hiddenCardCount) == [0, 0]) + } +} + +// MARK: - The board's own name + +struct BoardOutlineTitleTests { + + @Test func fallsBackToTheFolderNameWithoutTheExtension() throws { + let fixture = try OutlineFixture(named: "Quarterly Plan.kanban") + defer { fixture.tearDown() } + try fixture.index("", "schema: 1\n") + + // **A board never reads "Untitled"** — `AppModel.displayName(of:)`'s rule, one layer down: + // a board always has a folder, and the folder name is the Finder document name. + #expect(BoardOutline.read(boardRoot: fixture.root).title == "Quarterly Plan") + } + + @Test func aBlankTitleIsAnAbsentOne() throws { + let fixture = try OutlineFixture(named: "Roadmap.kanban") + defer { fixture.tearDown() } + try fixture.index("", "schema: 1\ntitle: \" \"\n") + + #expect(BoardOutline.read(boardRoot: fixture.root).title == "Roadmap") + } + + @Test func aFolderThatIsNotABoardStillPreviewsAsItself() throws { + let fixture = try OutlineFixture(named: "Not A Board.kanban") + defer { fixture.tearDown() } + try fixture.file("notes.txt", contents: "nothing to see") + + // No `index.md` at all: the walk cannot throw, so the floor is the folder's own name and no + // lanes — a truthful preview of a folder that is not a board. + let summary = BoardOutline.read(boardRoot: fixture.root) + #expect(summary.title == "Not A Board") + #expect(summary.lanes.isEmpty) + #expect(summary.laneCount == 0) + } + + @Test func aRootThatDoesNotExistIsNotAnError() { + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent("BoardOutlineTests-absent-\(UUID().uuidString).kanban") + + let summary = BoardOutline.read(boardRoot: missing) + #expect(summary.title.hasPrefix("BoardOutlineTests-absent-")) + #expect(summary.lanes.isEmpty) + } + + @Test func anUnreadableRootIndexFallsBackWithoutLosingTheLanes() throws { + let fixture = try OutlineFixture(named: "Broken Root.kanban") + defer { fixture.tearDown() } + // Not UTF-8: `BoardLoader` fails the whole board on this (01-storage-format.md § Malformed + // input). Here it costs the board's own fields and nothing else — the lanes are enumerated by + // folder shape and read their own files, so nothing below the root depends on it. + try Data([0xFF, 0xFE, 0x00]).write(to: fixture.root.appendingPathComponent("index.md")) + try fixture.index(identity("a"), "schema: 1\norder: 1024\ntitle: Doing\n") + + let summary = BoardOutline.read(boardRoot: fixture.root) + #expect(summary.title == "Broken Root") + #expect(summary.background == nil) + #expect(summary.lanes.map(\.title) == ["Doing"]) + } +} + +// MARK: - Untitled lanes and cards + +struct BoardOutlineUntitledTests { + + @Test func anUntitledLaneOrCardCarriesNilRatherThanAPlaceholder() throws { + let fixture = try OutlineFixture() + defer { fixture.tearDown() } + try fixture.index("", "schema: 1\ntitle: Board\n") + let lane = identity("a") + try fixture.index(lane, "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/\(identity("1"))", "schema: 1\norder: 1024\n") + try fixture.index("\(lane)/\(identity("2"))", "schema: 1\norder: 2048\ntitle: \"\"\n") + + // **"Untitled" is a rendering, never a value** (03-board-ui.md § Card face): the model says + // nothing, and the page is where the placeholder appears. A blank title reads the same as an + // absent one. + let summary = BoardOutline.read(boardRoot: fixture.root) + #expect(summary.lanes.count == 1) + #expect(summary.lanes[0].title == nil) + #expect(summary.lanes[0].cardTitles == [nil, nil]) + } + + @Test func aLaneWithBrokenFrontmatterIsKeptRatherThanDropped() throws { + let fixture = try OutlineFixture() + defer { fixture.tearDown() } + try fixture.index("", "schema: 1\ntitle: Board\n") + try fixture.index(identity("a"), "schema: 1\norder: 1024\ntitle: Doing\n") + // Not UTF-8 — the strict decode refuses it, exactly as `BoardLoader.parseDocument` does. + let broken = try fixture.emptyFolder(identity("b")) + try Data([0xFF, 0xFE, 0x00]).write(to: broken.appendingPathComponent("index.md")) + try fixture.index("\(identity("b"))/\(identity("1"))", "schema: 1\ntitle: Orphan\n") + + // The unreadable lane is order-less, so it appends past every ranked sibling — and its cards + // are still counted, because a preview that silently dropped a lane would disagree with + // Finder about what is in the folder. + let summary = BoardOutline.read(boardRoot: fixture.root) + #expect(summary.laneCount == 2) + #expect(summary.lanes.map(\.title) == ["Doing", nil]) + #expect(summary.lanes[1].cardCount == 1) + #expect(summary.lanes[1].cardTitles == ["Orphan"]) + } +} + +// MARK: - What counts as a lane or a card + +struct BoardOutlineCandidateTests { + + @Test func straysAreNeitherLanesNorCards() throws { + let fixture = try OutlineFixture() + defer { fixture.tearDown() } + try fixture.index("", "schema: 1\ntitle: Board\n") + let lane = identity("a") + try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Doing\n") + try fixture.index("\(lane)/\(identity("1"))", "schema: 1\norder: 1024\ntitle: Real\n") + + // Four shapes that are not levels, each for the reason the loader states: + try fixture.index("notes", "schema: 1\ntitle: Not a lane\n") // not identity-shaped + try fixture.emptyFolder(identity("b")) // no index.md + try fixture.emptyFolder("\(lane)/\(identity("2"))") // no index.md + try fixture.file("\(lane)/stray.md", contents: "loose") // a file, not a folder + try fixture.index(".trash/\(identity("9"))", "schema: 1\ntitle: Gone\n") // hidden + + let summary = BoardOutline.read(boardRoot: fixture.root) + #expect(summary.laneCount == 1) + #expect(summary.lanes[0].cardCount == 1) + #expect(summary.lanes[0].cardTitles == ["Real"]) + } +} + +// MARK: - Ordering + +struct BoardOutlineOrderingTests { + + @Test func lanesAndCardsFollowTheBoardsOwnDisplayOrder() throws { + let fixture = try OutlineFixture() + defer { fixture.tearDown() } + try fixture.index("", "schema: 1\ntitle: Board\n") + // Folder names ascend a, b, c while `order` descends — so a walk that trusted the listing + // rather than the rank would read backwards. + try fixture.index(identity("a"), "schema: 1\norder: 3000\ntitle: Third\n") + try fixture.index(identity("b"), "schema: 1\norder: 2000\ntitle: Second\n") + try fixture.index(identity("c"), "schema: 1\norder: 1000\ntitle: First\n") + try fixture.index("\(identity("c"))/\(identity("1"))", "schema: 1\norder: 20\ntitle: B\n") + try fixture.index("\(identity("c"))/\(identity("2"))", "schema: 1\norder: 10\ntitle: A\n") + + let summary = BoardOutline.read(boardRoot: fixture.root) + #expect(summary.lanes.map(\.title) == ["First", "Second", "Third"]) + #expect(summary.lanes[0].cardTitles == ["A", "B"]) + } + + @Test func anOrderlessSiblingAppendsAtTheEndByFolderName() throws { + let fixture = try OutlineFixture() + defer { fixture.tearDown() } + try fixture.index("", "schema: 1\ntitle: Board\n") + let lane = identity("a") + try fixture.index(lane, "schema: 1\norder: 1024\ntitle: Doing\n") + try fixture.index("\(lane)/\(identity("c"))", "schema: 1\ntitle: NoOrderC\n") + try fixture.index("\(lane)/\(identity("b"))", "schema: 1\ntitle: NoOrderB\n") + try fixture.index("\(lane)/\(identity("a"))", "schema: 1\norder: 5000\ntitle: Ranked\n") + + // `Ranks.resolvedOrders`' reading: past every ordered sibling, then by folder name + // (01-storage-format.md § Ordering, re-ruled 2026-07-31). + let summary = BoardOutline.read(boardRoot: fixture.root) + #expect(summary.lanes[0].cardTitles == ["Ranked", "NoOrderB", "NoOrderC"]) + } +} + +// MARK: - The caps + +struct BoardOutlineLimitTests { + + /// A board of `lanes` lanes, each holding `cards` cards, all ranked so display order is the + /// order they were made in. + private func board(lanes: Int, cards: Int) throws -> OutlineFixture { + let fixture = try OutlineFixture() + try fixture.index("", "schema: 1\ntitle: Board\n") + for lane in 0..