diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 87f587a..f251057 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -272,6 +272,11 @@ struct CardWindowHost: View { if case let .open(store) = phase, let placement { CardWindowView( card: placement.card, + store: store, + // The app's list, not the board's: the quick-style recents the sidebar's embedded + // editor feeds are app-wide state (02-architecture.md § Per-board app state), so + // they come from the model every window shares rather than from this board's store. + recents: appModel.styleRecents, cardFolder: Self.cardFolder(root: store.rootURL, placement: placement), bodyPresentation: bodyPresentation, bodySession: session.body, diff --git a/Kanban/App/FutureCommands.swift b/Kanban/App/FutureCommands.swift index e2ef27a..3bea2d5 100644 --- a/Kanban/App/FutureCommands.swift +++ b/Kanban/App/FutureCommands.swift @@ -85,9 +85,10 @@ struct FindSteppingCommands: View { /// did not move, the validation and the action filled in — and the pair also carries the clause that /// joins them, "Edit Body disables while Raw Source is active" (05-card-window.md). /// -// m6-card-sidebar: History is a plain command that focuses the sidebar's History section, and -// disables outright on mode `none` / repo-nested boards once that section exists (05-card-window.md, -// 07-sync-collab.md). It remains unconditionally disabled here — that surface does not exist yet. +// m7-git: History is a plain command that focuses the sidebar's History section, and disables +// outright on mode `none` / repo-nested boards once that section exists (05-card-window.md, +// 07-sync-collab.md). It remains unconditionally disabled here — the sidebar reserves the section's +// place (`CardWindowView.historySlot`) but draws nothing, so there is still no surface to focus. struct CardViewCommands: View { var body: some View { EditBodyCommand() diff --git a/Kanban/LiveStore/BoardStore.swift b/Kanban/LiveStore/BoardStore.swift index 105bc12..5331f0e 100644 --- a/Kanban/LiveStore/BoardStore.swift +++ b/Kanban/LiveStore/BoardStore.swift @@ -2462,6 +2462,34 @@ public final class BoardStore { tombstone(folders) } + /// **The card window's Actions ▸ Delete** (05-card-window.md ▸ Actions: "Delete — tombstones the + /// card … the window then dismisses itself"). + /// + /// The write is `tombstone(_:)`, so a card deleted from its own window is byte-indistinguishable + /// from one deleted with ⌫ on the board or dropped on the trash lane — one write op, one bracket, + /// one set of stamps. What differs is the same thing that differs for the drag, and for its + /// reason: **it says nothing about the selection.** ⌫ moves the board's selection to the deleted + /// card's successor sibling because the rule exists to make a repeated keystroke walk down a lane; + /// a button in another window has no such continuation, and the card it deletes need not be + /// selected on the board at all — picking a successor here would re-point a selection that never + /// lost anything. The ordinary reload does the rest: a live-side selection ejects a member that + /// flips to tombstoned, as the vanish it is. + /// + /// **It does not dismiss the window either**, and must not: the window's dismissal is a *fate* + /// re-derived from every snapshot (`CardWindowHost.cardWindowFate`), so the tombstone this writes + /// comes back through the watcher and the fate walk takes the window down — the same path an + /// agent's or another window's delete takes. A second dismissal from here would be a second rule + /// able to disagree with the first. + /// + /// A vanished or already-tombstoned card resolves to no path and writes nothing, `delete(_:)`'s + /// rule; liveness is ancestor-walked, so a card under a tombstoned lane is gone too — and its + /// window is already dismissing. + public func deleteCard(_ id: ItemID) { + let folders = TrashModel.paths(of: [id], on: .live, in: snapshot).map { $0.folder(under: rootURL) } + guard !folders.isEmpty else { return } + tombstone(folders) + } + /// The tombstone write itself — **one `performWrite` bracket, whatever the set's size and /// whichever gesture asked** (DRAG-REORDER.md § The drop commits; the style batch's rule). /// diff --git a/Kanban/UI/Card/CardDetailsSection.swift b/Kanban/UI/Card/CardDetailsSection.swift new file mode 100644 index 0000000..7f0e0de --- /dev/null +++ b/Kanban/UI/Card/CardDetailsSection.swift @@ -0,0 +1,130 @@ +import SwiftUI + +// MARK: - One row + +/// One **Details** row: a frontmatter key the app does not own, and the text it carries +/// (05-card-window.md ▸ Details). +/// +/// `id` is the key because the effective frontmatter view has one entry per key — a key written +/// twice reads once, at its winning occurrence (`FrontmatterDocument.parse`, the last-wins rule) — +/// so there is nothing here for a duplicate to collide with. +struct CardDetailRow: Identifiable, Equatable, Sendable { + let key: String + let value: String + + var id: String { key } +} + +// MARK: - The seam + +/// What the Details section shows, as a pure function of a parsed `index.md` +/// (05-card-window.md ▸ Details). +/// +/// ### Which keys +/// +/// **Every unknown key, and only unknown keys, in file order.** The set is +/// `FrontmatterDocument.unknownFields` unchanged — the schema's eleven names are out, and everything +/// else is in, *including* the reserved enhanced-schema names (`labels`, `assignees`, `due`, +/// `remote`, …), which "are ordinary unknown keys in this version and appear here like any other — +/// no special rendering". Order is the document's, which is the file's: 01-storage-format.md +/// preserves key order verbatim and the sidebar honors it, so a hand-written key sits where the hand +/// that wrote it put it. +/// +/// Nothing here reaches for a card's folder or re-reads a file: the snapshot's `Card` already +/// carries its whole parsed document, so this section shows exactly what the last reload read, like +/// every other surface in the window. +/// +/// ### Which text +/// +/// **The rawest honest form the parse can offer, and never an error.** A value written on one line +/// renders as the author's own bytes — `rawValue`, which is the span minus the `key:` header, the +/// surrounding whitespace and any trailing comment (a comment belongs to the line, not to the +/// value). Quotes, hex, an ISO timestamp, a flow collection all read exactly as typed, which is the +/// whole point of a section that exists to show what the app did *not* interpret. +/// +/// Two shapes have no single-line source text to show, and both fall back to the engine's own +/// reading of the value (`YAMLValue.description`): +/// +/// - **A value spanning several lines** — a block scalar, a multi-line flow collection. Its raw span +/// carries YAML syntax the value does not (`|`, `>-`, the continuation indent), so the bytes would +/// be a worse answer than the text they encode. +/// - **A value with no text at all** — `project:` with nothing after it. The rawest form is the empty +/// string, and a row with a key and a blank beside it reads as a bug rather than as YAML's null. +/// +/// Neither branch can throw and neither can fail: the document was parsed before a `Card` existed at +/// all, and every reading below is total over `YAMLValue`. "Exotic YAML shapes display best-effort, +/// never error" is that, exactly. +enum CardDetails { + + /// The section's rows, or `[]` when the card carries no unknown keys — which is also the whole + /// of the section's visibility rule ("shown only when any exist"), stated where a test can hold + /// it rather than as an `if` in a view. + nonisolated static func rows(of document: FrontmatterDocument) -> [CardDetailRow] { + document.unknownFields.map { CardDetailRow(key: $0.key, value: display(of: $0)) } + } + + /// One field's display text — see the type's doc comment for the rule and its two fallbacks. + nonisolated static func display(of field: FrontmatterField) -> String { + let raw = field.rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + if !raw.isEmpty, !raw.contains(where: \.isNewline) { return raw } + return field.value.description.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +// MARK: - The section + +/// The sidebar's **Details** section: read-only key/value rows for every unknown frontmatter key +/// (05-card-window.md ▸ Details). +/// +/// ### There is no edit affordance, and that is the design +/// +/// "Editing is deliberately not offered: the **raw source outlet** is the write path for frontmatter +/// the app doesn't own." So these rows are text — selectable, copyable, and nothing else. A field +/// here would have to guess a YAML shape for whatever the user typed, into a key whose meaning the +/// app has no opinion about; ⌥⌘E edits the file itself, where the guess is the user's. +/// +/// ### Absent, not empty +/// +/// The section disappears entirely on a card with no unknown keys — which is most cards. Contrast +/// Attachments, which keeps a hint when empty because it advertises a drop surface the user has to +/// be able to find: there is nothing to teach here, and a permanent empty "Details" header would +/// imply the card has details it is failing to show. +struct CardDetailsSection: View { + + let rows: [CardDetailRow] + + private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } + + var body: some View { + if !rows.isEmpty { + VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) { + CardSidebarSectionHeader(title: "Details") + ForEach(rows) { row in + self.row(row) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + /// Key over value rather than key beside value: the sidebar is 26 characters wide, and a + /// two-column row would give a `project:` overlay four characters to say `lanework` in. The value + /// wraps to as many lines as it needs — the sidebar scrolls, and a truncated value in a section + /// whose only job is to show values would be the one thing worse than no section at all. + private func row(_ row: CardDetailRow) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(row.key) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Text(row.value) + .font(.callout) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(row.key), \(row.value)") + } +} diff --git a/Kanban/UI/Card/CardSidebarSections.swift b/Kanban/UI/Card/CardSidebarSections.swift new file mode 100644 index 0000000..e1b77ba --- /dev/null +++ b/Kanban/UI/Card/CardSidebarSections.swift @@ -0,0 +1,135 @@ +import AppKit +import SwiftUI + +// MARK: - Style + +/// The sidebar's **Style** section: "the **embedded style editor** — background palette grid (with +/// the leading None well) and curated symbol grid, per 03-board-ui.md ▸ Styling ▸ Controls. Card +/// styling is discoverable here without a context menu; the same component appears in the board +/// popover and behind Style…" (05-card-window.md ▸ Style). +/// +/// ### How this anchor differs from the other two — in one word, geometry +/// +/// It hosts `StyleEditorView` itself, not a copy of it: the wells, the batch display, the arrow-key +/// grammar, the read-only disabling, the recents the None well deliberately does not record, and the +/// single `applyStyle` bracket every well's click rides are all the shared component's, identical +/// here. The only thing this anchor supplies beyond a target is a `StyleEditorLayout` — the sidebar +/// is narrower than the popover at every text size, so the grids fall in fewer columns and the +/// symbol grid draws whole instead of scrolling inside the sidebar's own scroll view. +/// +/// ### The target is fixed, and that is the whole difference in behavior +/// +/// The Style… popover *tracks*: its target is the selection at the moment the gesture named it, +/// re-resolved against every snapshot, dismissing when it empties (`StyleEditorSession`). This +/// section tracks nothing. Its target is this window's card, always — "the two embedded anchors need +/// none of this and get none: the card sidebar dismisses with its card's window, and the board +/// popover's target is the board itself" (`StyleEditorSession`'s own note). Which is why there is no +/// session here to resolve, no popover to dismiss, and no way for a board-side selection change to +/// re-aim the editor a card window is showing: the window's card is the target by construction, and +/// when that card stops existing the window goes with it (`CardWindowFate`). +struct CardStyleSection: View { + + let store: BoardStore + let recents: StyleRecents + let cardID: ItemID + + /// The live body metric, read here rather than passed in — `CardAttachmentsSection`'s pattern, + /// so every section in this sidebar derives its geometry the same way. + private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } + + /// **What this section styles: this card, and nothing else.** + /// + /// A one-line seam because it is a claim worth pinning rather than a computation worth reading: + /// every other anchor derives its target from something that moves (the selection, the board), + /// and the mistake this window could make is inheriting one of those. `.items` of exactly one id + /// also means the editor's own batch machinery is a no-op here — one subject, so the display is + /// never mixed and the write bracket holds one file. + nonisolated static func target(forCard id: ItemID) -> StyleTarget { + .items([id]) + } + + var body: some View { + VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) { + CardSidebarSectionHeader(title: "Style") + StyleEditorView( + store: store, + recents: recents, + target: Self.target(forCard: cardID), + layout: .sidebar(contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: pointSize)) + ) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Actions + +/// The sidebar's **Actions** section, at the bottom of the stack (05-card-window.md ▸ Actions): +/// **Delete** — tombstones the card — and **Reveal in Finder** — the card's folder. +/// +/// ### Delete writes; the window's dismissal is not its business +/// +/// The button calls `BoardStore.deleteCard`, which is the ⌫ tombstone exactly (same write op, same +/// bracket, same stamps). It does not close this window: the card's tombstone rounds back through +/// the watcher and `CardWindowHost.cardWindowFate` takes the window down, which is the same path a +/// delete from the board — or from an agent — already takes. Dismissing from here as well would be a +/// second rule able to disagree with the first, and 05's own wording is a sequence rather than a +/// pair ("tombstones the card; the window then dismisses itself"). +/// +/// Recovery is the board's trash quasi-lane, which is why this needs no confirmation: the row is +/// still there to Put Back, and 03-board-ui.md reserves the alert for the purge that isn't +/// recoverable. +/// +/// ### Reveal is not edit-shaped +/// +/// So it stays enabled under the read-only lock, where Delete does not — inspection is a read (04 ▸ +/// The trash's posture, shared by the trash row's own Reveal). What it reveals comes from +/// `CardAttachments.revealURLs`, the same rule File ▸ Reveal in Finder's card-window scope answers +/// through: this button is that rule's card-folder branch by construction, since it is the *card's* +/// action rather than the attachment list's. +struct CardActionsSection: View { + + let store: BoardStore + let cardID: ItemID + /// The card's own folder — `nil` only where the window has no board to build it from, which is a + /// window on its way out. + let cardFolder: URL? + + private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize } + + private var revealURLs: [URL] { + CardAttachments.revealURLs(cardFolder: cardFolder, selectedURL: nil, isSectionFocused: false) + } + + var body: some View { + VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) { + CardSidebarSectionHeader(title: "Actions") + + // Delete above Reveal, which is the order 05 lists them in. Destructive styling, per 05 + // — the one control in this window that takes the card away. Disabled under the + // read-only lock like every other mutation (02-architecture.md's every-entry-point + // predicate); that is the attachments section's `isEditable`, read from the store + // directly because there is no handle to route it through here and nothing else in this + // section that would want one. + Button(role: .destructive) { + store.deleteCard(cardID) + } label: { + // The width is the *label's*, not the button's: a bordered button sizes to its label, + // so a frame around the button would centre a small pill in a wide row instead of + // filling it. Both rows do it, so the two are one column rather than two widths. + Text("Delete").frame(maxWidth: .infinity) + } + .tint(.red) + .disabled(store.isReadOnly) + + Button { + NSWorkspace.shared.activateFileViewerSelecting(revealURLs) + } label: { + Text("Reveal in Finder").frame(maxWidth: .infinity) + } + .disabled(revealURLs.isEmpty) + } + .buttonStyle(.bordered) + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Kanban/UI/Card/CardWindowMetrics.swift b/Kanban/UI/Card/CardWindowMetrics.swift index b56e78d..fac3235 100644 --- a/Kanban/UI/Card/CardWindowMetrics.swift +++ b/Kanban/UI/Card/CardWindowMetrics.swift @@ -66,6 +66,16 @@ enum CardWindowMetrics { columnWidth(characters: sidebarCharacters, bodyPointSize: bodyPointSize) } + /// What a sidebar *section* actually gets to lay out in: the column minus its two gutters. + /// + /// Named because one section needs a number rather than a proposal — the embedded style editor's + /// grids are a fixed count of fixed-size wells per row, and the count has to be decided before + /// the layout runs (`StyleEditorLayout.sidebar(contentWidth:)`). Everything else in the sidebar + /// simply fills what it is proposed and never asks. + static func sidebarContentWidth(bodyPointSize: CGFloat) -> CGFloat { + sidebarWidth(bodyPointSize: bodyPointSize) - 2 * gutter(bodyPointSize: bodyPointSize) + } + // MARK: - The body column /// The narrowest the body column is allowed to get — a measure of prose short enough to be a @@ -79,6 +89,15 @@ enum CardWindowMetrics { columnWidth(characters: bodyMinimumCharacters, bodyPointSize: bodyPointSize) } + // MARK: - A sidebar section + + /// The gap between a sidebar section's header and its content, and between two rows of it — + /// half a gutter, which is the attachment rows' inset and the rendered body's rhythm too, so the + /// whole window is spaced by one unit rather than by three that happen to agree. + static func sidebarRowSpacing(bodyPointSize: CGFloat) -> CGFloat { + previewPadding(bodyPointSize: bodyPointSize) + } + // MARK: - The attachments section /// An attachment row's thumbnail: a **small** square, one and a half ems on a side diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift index dcae306..b9dfa43 100644 --- a/Kanban/UI/Card/CardWindowView.swift +++ b/Kanban/UI/Card/CardWindowView.swift @@ -15,7 +15,8 @@ import UniformTypeIdentifiers /// reads or writes beyond that is later work and is marked where it lands: /// /// - the title as an editable field (commit on Return / focus loss, Escape abandons), -/// - the sidebar's five sections, which are section *headers* here and nothing more. +/// - the sidebar's History section, whose place in the stack is reserved and whose content waits on +/// a git mode to be honest about (`historySlot`). /// /// The placeholders are structural rather than apologetic: the sidebar's inventory and its order are /// settled (05 ▸ The attributes sidebar), so the shell states them and the sections fill in @@ -39,6 +40,20 @@ import UniformTypeIdentifiers struct CardWindowView: View { let card: Card + /// The board this card belongs to. + /// + /// The one place in this window a whole store is handed to a view rather than a narrow seam, and + /// the sidebar is why: the Style section hosts the **shared** style editor, whose API is + /// store-shaped by design (it reads the target set's current values and writes through the one + /// `applyStyle` bracket every anchor shares), and the Actions section's Delete is the store's own + /// tombstone. Routing either through a closure of this window's own would be a second card-styling + /// or card-deleting path to keep in step with the first — exactly what "one component, one + /// behavior" and "exactly the ⌫ tombstone" forbid. + let store: BoardStore + /// The app-wide quick-style recents the embedded editor feeds (03-board-ui.md ▸ Styling ▸ + /// Controls) — app state, not board state, which is why it arrives beside the store rather than + /// on it. + let recents: StyleRecents /// The card's folder on disk — what relative images and links in the body resolve against /// (05 ▸ Preview). `nil` only where a caller has no board root to build it from. let cardFolder: URL? @@ -194,40 +209,50 @@ struct CardWindowView: View { // MARK: - Attributes sidebar - /// The sidebar's sections, **in 05's settled order**, as headers over empty space. + /// The sidebar's sections, **in 05's settled order**: Attachments, Style, Details, History, + /// Actions. /// - /// Two of them are conditional once they have content — Details appears only when the card - /// carries unknown frontmatter keys, and History is absent on boards without app-managed git — - /// and the shell shows them unconditionally because it has neither the key inventory nor a git - /// mode to consult yet. That is the one place these placeholders are not yet the final - /// composition, and it resolves when the sections do. + /// Two of the five are conditional, and both conditions are the section's own rather than a rule + /// restated here: **Details** renders nothing when the card carries no unknown frontmatter keys + /// ("shown only when any exist"), and **History** is absent on boards without app-managed git. + /// Everything else in the stack is unconditional, so the composition a user learns on one card is + /// the composition they get on the next. private var sidebar: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: bodyPointSize * 1.25) { CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails) - // m6-card-sidebar: the embedded style editor — the same component the board popover - // and Style… already host (`StyleEditor`). - section("Style") - // m6-card-sidebar: read-only key/value rows for every unknown frontmatter key, in - // file order. - section("Details") - // m7-git: the card's commit trail, read-only; absent on mode none / repo-nested. - section("History") - // m6-card-sidebar: Delete (tombstones, the window then dismisses itself) and Reveal - // in Finder. - section("Actions") + + CardStyleSection(store: store, recents: recents, cardID: card.id) + + // The snapshot's own document, not a re-read: the loader parsed this file, unknown + // keys and their order included, and `Card` has carried it since (`BoardModel`). + CardDetailsSection(rows: CardDetails.rows(of: card.document)) + + historySlot + + CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder) } .frame(maxWidth: .infinity, alignment: .leading) .padding(CardWindowMetrics.gutter(bodyPointSize: bodyPointSize)) } } - /// A stacked small-caps header over the space its section will occupy (05: "Stacked sections - /// under small-caps headers") — the same header the Attachments section fills in for real, so - /// the four still-empty ones cannot drift from it. - private func section(_ title: String) -> some View { - CardSidebarSectionHeader(title: title) - .accessibilityElement(children: .combine) + /// **The History section's reserved place in the stack** — between Details and Actions, 05's + /// order (05 ▸ History: "the card's commit trail, read-only … newest first — semantic subject, + /// relative date, author"). + /// + /// Nothing is drawn yet, deliberately: the section is conditional on a git mode that does not + /// exist here, so a header over empty space would claim a commit trail on every board — and on + /// the boards where it is *absent* by design (mode none, repo-nested) it would be claiming one + /// that can never arrive. What the slot reserves is the **position**, so filling it in moves + /// nothing above or below it. + /// + // m7-git: the trail itself, plus the two rules that come with it — absence on boards without + // app-managed git (the same honesty rule as the board popover's git section, 06-history-undo.md) + // and View ▸ History, which focuses this section (11-command-nexus.md). + @ViewBuilder + private var historySlot: some View { + EmptyView() } } diff --git a/Kanban/UI/StyleEditor.swift b/Kanban/UI/StyleEditor.swift index 3523467..a688194 100644 --- a/Kanban/UI/StyleEditor.swift +++ b/Kanban/UI/StyleEditor.swift @@ -11,6 +11,10 @@ import SwiftUI /// what lets "one component, one behavior, three anchors" be a fact about the code rather than a /// promise. The Style… popover's *lifecycle* lives elsewhere for the same reason: it is a reload /// rule, and it belongs with the other reload rules (`StyleEditorSession`, `TransientBoardState`). +/// +/// The one thing here that *names* an anchor is `StyleEditorLayout`, and it names only geometry: a +/// popover is a window this app sizes and a sidebar section is a column the window sizes, so the two +/// cannot share a frame. Nothing behavioral hangs off it — see its own doc comment. // MARK: - The write funnel @@ -89,6 +93,77 @@ enum CuratedSymbols { static var available: [String] { all.filter(ItemSymbol.exists) } } +// MARK: - The anchor's chrome + +/// Everything about the editor that is the **anchor's** business rather than the editor's: how wide +/// it is, what padding it brings, how many wells fall in a row, and whether its symbol grid scrolls. +/// +/// **It exists so "one component, one behavior, another anchor" survives an anchor that is not a +/// popover** (05-card-window.md ▸ Style: the card sidebar embeds this same editor). A popover is a +/// window the app sizes; a sidebar section is a column the window sizes — and the 268-point frame +/// that makes the first one narrow enough to sit beside a card would overflow the second by 70 +/// points. Nothing about *behavior* is in here: every well, every write, the batch display and the +/// keyboard grammar are the editor's, identical at every anchor. Only the geometry moves. +struct StyleEditorLayout: Equatable { + + /// One well's side, and the gap between two — the numbers the grids are laid out on, named once + /// so the fit rule below and the wells themselves cannot drift apart. + static let wellSide: CGFloat = 20 + static let wellSpacing: CGFloat = 6 + + /// A fixed width, or `nil` to take whatever the anchor proposes. + var width: CGFloat? + /// The editor's own inset. Zero where the anchor already insets its column. + var padding: CGFloat + var backgroundColumns: Int + var symbolColumns: Int + /// How tall the symbol grid may grow before it scrolls inside itself, or `nil` for "never" — + /// the grid then draws whole and the anchor scrolls it. + var symbolGridMaximumHeight: CGFloat? + + /// The Style… popover and the board popover's styling area: a fixed frame, its own padding, and + /// a symbol grid that scrolls within it. + /// + /// Thirteen background wells (None + the twelve) fall as 7 + 6, which keeps the popover narrow + /// enough to sit beside a card without covering the lane it came from; the symbol grid's cap is + /// eight rows or so — enough that it reads as a set rather than as a strip, short enough that the + /// popover fits beside a card on a laptop screen. + static let popover = StyleEditorLayout( + width: 268, + padding: 14, + backgroundColumns: 7, + symbolColumns: 8, + symbolGridMaximumHeight: 168 + ) + + /// The card window's sidebar section (05-card-window.md ▸ Style). + /// + /// - **No width and no padding of its own**: the sidebar's width is `CardWindowMetrics`' one + /// decision and its gutter is already applied to the whole section stack, so an editor with an + /// opinion here would either overflow the column or inset twice. + /// - **As many wells per row as the column holds**, rather than the popover's 7 and 8 — the + /// sidebar is narrower than the popover at every text size, and a grid wider than its column is + /// a grid with wells the pointer cannot reach. + /// - **The symbol grid does not scroll.** The sidebar is already a scroll view, and a scroll view + /// inside a scroll view is a scroll view that fights (`CardWindowView`'s rule, for its reason). + static func sidebar(contentWidth: CGFloat) -> StyleEditorLayout { + let columns = columns(fitting: contentWidth) + return StyleEditorLayout( + width: nil, + padding: 0, + backgroundColumns: columns, + symbolColumns: columns, + symbolGridMaximumHeight: nil + ) + } + + /// How many wells fit across `width` — `n` wells and `n - 1` gaps, floored, and never less than + /// one. Pure, and the whole of "the grid never overflows the column it was given". + static func columns(fitting width: CGFloat) -> Int { + max(1, Int((width + wellSpacing) / (wellSide + wellSpacing))) + } +} + // MARK: - The editor /// The style editor: a background section and a symbol section, each a leading "no value" well @@ -112,11 +187,9 @@ struct StyleEditorView: View { let store: BoardStore let recents: StyleRecents let target: StyleTarget - - /// Wells per row. Thirteen background wells (None + the twelve) fall as 7 + 6, which keeps the - /// popover narrow enough to sit beside a card without covering the lane it came from. - private let backgroundColumns = 7 - private let symbolColumns = 8 + /// The anchor's geometry, and nothing else (`StyleEditorLayout`). Defaulted to the popover's, so + /// the two anchors that were here first say nothing about it. + var layout: StyleEditorLayout = .popover var body: some View { let subjects = store.styleSubjects(of: target) @@ -129,8 +202,8 @@ struct StyleEditorView: View { Divider() symbolSection(icon) } - .padding(14) - .frame(width: 268) + .padding(layout.padding) + .frame(width: layout.width) // The read-only lock and the focused-editor rule disable every mutating surface, not only // the menu items (02-architecture.md § The lock's scope) — an editor whose wells would be // refused should not look available. The popover stays *open*: the lock is a condition the @@ -163,7 +236,7 @@ struct StyleEditorView: View { sectionHeader("Background", current: backgroundCurrent(state)) StyleWellGrid( wells: backgroundWells(state), - columns: backgroundColumns, + columns: layout.backgroundColumns, apply: { change in StyleCommand.apply(background: change, to: target, in: store, recents: recents) } @@ -204,18 +277,27 @@ struct StyleEditorView: View { let fallback = ItemSymbol.default(for: level) return VStack(alignment: .leading, spacing: 8) { sectionHeader("Symbol", current: symbolCurrent(state, fallback: fallback)) - ScrollView(.vertical) { - StyleWellGrid( - wells: symbolWells(state, fallback: fallback), - columns: symbolColumns, - apply: { change in - StyleCommand.apply(icon: change, to: target, in: store, recents: recents) - } - ) + symbolGrid(state, fallback: fallback) + } + } + + /// The curated grid, scrolling within its own cap or drawn whole — the anchor's call + /// (`StyleEditorLayout.symbolGridMaximumHeight`), and the one shape difference between the + /// popover and the card sidebar. + @ViewBuilder + private func symbolGrid(_ state: StyleFieldState, fallback: String) -> some View { + let grid = StyleWellGrid( + wells: symbolWells(state, fallback: fallback), + columns: layout.symbolColumns, + apply: { change in + StyleCommand.apply(icon: change, to: target, in: store, recents: recents) } - // Eight rows or so before it scrolls: enough that the grid reads as a set rather than as - // a strip, short enough that the popover fits beside a card on a laptop screen. - .frame(maxHeight: 168) + ) + if let maximumHeight = layout.symbolGridMaximumHeight { + ScrollView(.vertical) { grid } + .frame(maxHeight: maximumHeight) + } else { + grid } } @@ -316,7 +398,7 @@ private struct StyleWellFace: View { } let face: Face - var size: CGFloat = 20 + var size: CGFloat = StyleEditorLayout.wellSide var body: some View { switch face { @@ -380,8 +462,11 @@ private struct StyleWellGrid: View { var body: some View { LazyVGrid( - columns: Array(repeating: GridItem(.flexible(minimum: 20), spacing: 6), count: columns), - spacing: 6 + columns: Array( + repeating: GridItem(.flexible(minimum: StyleEditorLayout.wellSide), spacing: StyleEditorLayout.wellSpacing), + count: columns + ), + spacing: StyleEditorLayout.wellSpacing ) { ForEach(wells) { well in Button { diff --git a/KanbanTests/CardSidebarTests.swift b/KanbanTests/CardSidebarTests.swift new file mode 100644 index 0000000..31f1225 --- /dev/null +++ b/KanbanTests/CardSidebarTests.swift @@ -0,0 +1,415 @@ +import Foundation +import Testing +@testable import Kanban + +/// The card window's sidebar sections, reduced to the rules a unit test can hold (05-card-window.md +/// ▸ The attributes sidebar). +/// +/// The views are not the point and are not tested. What is tested is the three seams under them, +/// each of which fails *silently* — the failure mode this file exists for: +/// +/// - **Details** shows frontmatter the app deliberately does not understand, so nothing downstream +/// can notice when a key goes missing, arrives out of order, or renders as a YAML indicator. The +/// only check on it is a test that reads a file and says what the rows must be. +/// - **Style** embeds a shared, selection-aware component in a window that has no selection. An +/// anchor wired to the wrong target would look completely normal until it restyled something else. +/// - **The sidebar's geometry** is a grid of fixed-size wells in a column sized from font metrics: +/// at the wrong column count the last well in each row is simply unreachable, at no text size +/// anyone tests by eye. + +// MARK: - Details ▸ which keys + +@Suite("Card details ▸ keys") +struct CardDetailsKeyTests { + + /// One card carrying every kind of key at once: the schema's own, an agent overlay, four + /// reserved enhanced-schema names, and a key written twice. + private static let card = """ + --- + schema: 1 + title: Fix login + order: 1024 + project: lanework # agent overlay + labels: [a, b, c] + assignees: + - ada + - grace + due: 2026-08-01 + remote: {name: origin, branch: main} + created: 2026-01-01T09:00:00Z + modified: 2026-02-02T09:00:00Z + modified-by: claude + background: mint + icon: flag + iconColor: carnation + project: overlay-rewritten + --- + Body text. + + """ + + @Test("Every unknown key appears, in file order") + func unknownKeysInFileOrder() throws { + let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card)) + + // File order, verbatim — 01-storage-format.md preserves it and the sidebar honors it. The + // twice-written `project` reads once, and it reads where its *winning* occurrence sits: the + // effective view a duplicate collapses to (`FrontmatterDocument.parse`, last-wins), which is + // also the order the file itself takes the moment anything rewrites that key. + #expect(rows.map(\.key) == ["labels", "assignees", "due", "remote", "project"]) + } + + @Test("Reserved enhanced-schema keys are ordinary unknown keys in this version") + func reservedKeysAreShown() throws { + let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card)) + + // "`labels`, `assignees`, `due`, `remote`, … are ordinary unknown keys in this version and + // appear here like any other — no special rendering" (05 ▸ Details). The day they gain + // meaning they leave this section for a control; until then, hiding them would hide data the + // file plainly has. + for reserved in ["labels", "assignees", "due", "remote"] { + #expect(rows.contains { $0.key == reserved }, "\(reserved) belongs in Details") + } + } + + @Test("Every schema-owned key is excluded — including the ones the sidebar shows elsewhere") + func schemaKeysAreExcluded() throws { + let rows = CardDetails.rows(of: try FrontmatterDocument.parse(Self.card)) + let keys = Set(rows.map(\.key)) + + // The eleven the app owns. `title`, `created`/`modified`/`modified-by` and the three style + // keys have their own surfaces in this very window (the title field, the date line, the Style + // section), and `schema`/`order`/`width`/`deleted` are structure — a Details row for any of + // them would be the same fact stated twice, in a section whose whole premise is "keys the app + // does not own". + #expect(keys.isDisjoint(with: FrontmatterKeys.schemaOwned)) + #expect(keys == ["labels", "assignees", "due", "remote", "project"]) + } + + @Test("A card with no unknown keys has no section at all") + func theSectionDisappearsWithNothingToShow() throws { + // The visibility rule, as the seam states it: "shown only when any exist" (05 ▸ Details). + // Empty rows are the view's whole condition, so this is that condition. + let plain = "---\nschema: 1\ntitle: Plain\norder: 1024\n---\nBody.\n" + #expect(CardDetails.rows(of: try FrontmatterDocument.parse(plain)).isEmpty) + + // Style keys and stamps are not "details" either — a styled, stamped card still has none. + let styled = """ + --- + schema: 1 + title: Styled + order: 1024 + background: mint + icon: flag + iconColor: carnation + created: 2026-01-01T09:00:00Z + modified: 2026-02-02T09:00:00Z + modified-by: claude + width: 2 + deleted: 2026-03-03T09:00:00Z + --- + + """ + #expect(CardDetails.rows(of: try FrontmatterDocument.parse(styled)).isEmpty) + } + + @Test("The rows come off the snapshot's own card, unknown keys and order intact") + func theSnapshotCarriesTheKeys() throws { + let fixture = try WriterFixture() + defer { fixture.tearDown() } + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Self.card) + + // Nothing was added to the loader for this section: `Card.document` is the whole parsed + // `index.md` and has been since the model existed, so the section reads what the last reload + // read — no second parse, no directory walk, no freshness rule of its own. + let model = try BoardLoader.load(boardRoot: fixture.root).model + let card = try #require(model.lanes.first?.cards.first) + + #expect(CardDetails.rows(of: card.document).map(\.key) == ["labels", "assignees", "due", "remote", "project"]) + #expect(CardDetails.rows(of: card.document).first?.value == "[a, b, c]") + } +} + +// MARK: - Details ▸ which text + +@Suite("Card details ▸ values") +struct CardDetailsValueTests { + + private func rows(_ frontmatter: String) throws -> [String: String] { + let text = "---\nschema: 1\norder: 1024\n\(frontmatter)---\nBody.\n" + return Dictionary(uniqueKeysWithValues: CardDetails.rows(of: try FrontmatterDocument.parse(text)) + .map { ($0.key, $0.value) }) + } + + @Test("A one-line value renders as the author's own bytes") + func singleLineValuesAreVerbatim() throws { + let values = try rows(""" + plain: lanework + flow-map: {name: origin, branch: main} + flow-seq: [a, b, c] + hex: "#FF8800" + quoted: "a # b" + commented: kept # this comment is the line's, not the value's + stamp: 2026-08-01T09:00:00Z + number: 2048 + + """) + + // The rawest honest form: quotes, braces, brackets, a hash inside a quoted scalar — all of it + // exactly as typed, because a section that exists to show what the app did *not* interpret + // must not quietly re-serialize it. + #expect(values["plain"] == "lanework") + #expect(values["flow-map"] == "{name: origin, branch: main}") + #expect(values["flow-seq"] == "[a, b, c]") + #expect(values["hex"] == "\"#FF8800\"") + #expect(values["quoted"] == "\"a # b\"") + #expect(values["stamp"] == "2026-08-01T09:00:00Z") + #expect(values["number"] == "2048") + // A trailing comment is the *line's*, and dropping it is the engine's own read-side rule — + // the bytes on disk keep it. + #expect(values["commented"] == "kept") + } + + @Test("A value spanning several lines reads as its text, not as its YAML syntax") + func multiLineValuesFallBackToTheParsedReading() throws { + let values = try rows(""" + notes: | + first line + second line + folded: >- + wrapped + prose + block-seq: + - ada + - grace + block-map: + name: origin + branch: main + wrapped-flow: [one, + two] + + """) + + // The raw span of a block scalar carries `|`, `>-` and the continuation indent — YAML syntax + // the *value* does not have. So these fall back to the engine's own reading, which is the + // text the author meant rather than the punctuation that encoded it. + #expect(values["notes"] == "first line\nsecond line") + #expect(values["folded"] == "wrapped prose") + #expect(values["block-seq"] == "[ada, grace]") + #expect(values["block-map"] == "{name: origin, branch: main}") + #expect(values["wrapped-flow"] == "[one, two]") + } + + @Test("A key with no value reads as null rather than as a blank") + func emptyValuesReadAsNull() throws { + let values = try rows(""" + sphere: + explicit: null + tilde: ~ + empty-string: "" + only-a-comment: # nothing but this + space-after: " " + + """) + + // A row showing a key and nothing beside it reads as a bug in the app; YAML's own word for + // the value is the honest thing to draw. An explicitly *empty string* is a different value + // and keeps its own bytes. + #expect(values["sphere"] == "null") + #expect(values["explicit"] == "null") + #expect(values["tilde"] == "~") + #expect(values["only-a-comment"] == "null") + #expect(values["empty-string"] == "\"\"") + #expect(values["space-after"] == "\" \"") + } + + @Test("A duplicated key reads once, with the winning occurrence's value") + func duplicateKeysCollapseLastWins() throws { + let document = try FrontmatterDocument.parse(""" + --- + schema: 1 + order: 1024 + project: first + sphere: home + project: second + --- + Body. + + """) + let rows = CardDetails.rows(of: document) + + // 01-storage-format.md's deliberate divergence from strict YAML, surfaced: the section shows + // what the app *reads*, and the app reads the last occurrence. Two rows for one key would say + // the card has a value it does not have. + #expect(rows.map(\.key) == ["sphere", "project"]) + #expect(rows.map(\.value) == ["home", "second"]) + } + + @Test("Frontmatter the editor cannot address still renders — readable-but-uneditable") + func anUneditableDocumentStillShowsItsKeys() throws { + // A whole-frontmatter flow mapping: no key has a line of its own, so `rawValue` has no span + // to read and every value falls back to the parsed reading. The document refuses *writes* + // (`uneditableShape`) — this section only reads, so it shows the keys like any other card's. + let document = try FrontmatterDocument.parse(""" + --- + {schema: 1, order: 1024, project: lanework, labels: [a, b]} + --- + Body. + + """) + #expect(document.uneditableShape != nil) + #expect(CardDetails.rows(of: document).map(\.key) == ["project", "labels"]) + #expect(CardDetails.rows(of: document).map(\.value) == ["lanework", "[a, b]"]) + } + + @Test("Nothing a parsed document can hold makes a row throw or vanish") + func exoticShapesAreLenient() throws { + // "Values render as plain text, leniently — exotic YAML shapes display best-effort, never + // error" (05 ▸ Details). Every shape here has *some* row, and none of them is empty: an + // unreadable value must still say that the key is there, since the raw source outlet is the + // only way to fix it and the user has to know to go there. + let values = try rows(""" + nested: {a: {b: [1, 2, {c: d}]}} + booleans: [true, false, yes, no] + unicode: "日本語 — ✂ \\u00e9" + anchored: &a value + aliased: *a + colon-in-value: "key: not a key" + tabbed: "a\\tb" + big: 123456789012345678901234567890 + exponent: 1.2e+34 + infinity: .inf + not-a-number: .nan + dashes: "- not a list" + + """) + + #expect(values.count == 12) + for (key, value) in values { + #expect(!value.isEmpty, "\(key) rendered as nothing at all") + } + // An alias resolves to `.null` in the snapshot's value view, but its *source span* is what + // the row shows — so an aliased value states the alias rather than lying about being empty. + #expect(values["aliased"] == "*a") + #expect(values["infinity"] == ".inf") + #expect(values["not-a-number"] == ".nan") + #expect(values["colon-in-value"] == "\"key: not a key\"") + } +} + +// MARK: - Style ▸ the anchor + +@Suite("Card sidebar ▸ style anchoring") +struct CardStyleAnchorTests { + + @Test("The section's target is this window's card, and only this window's card") + func theTargetIsTheWindowsCard() { + let card = ItemID(rawValue: Ident.card1) + + // The card window has no selection and inherits none: "the two embedded anchors need none of + // this and get none — the card sidebar dismisses with its card's window" (`StyleEditorSession`). + // A target derived from anything that moves is the one way this anchor could restyle + // something the user is not looking at. + #expect(CardStyleSection.target(forCard: card) == .items([card])) + + // Never the board — the fallback the *selection-aware* anchor takes with nothing selected, + // and the one this anchor must never reach: a card window styling the whole board would + // repaint every lane behind it. + #expect(CardStyleSection.target(forCard: card) != .board) + } + + @Test("A card respelled in caps is the same target") + func theTargetIsKeyedByIdentityNotSpelling() { + // The window's own key rule (`CardWindowRef`), so the section cannot disagree with the window + // it sits in about which card it is aimed at. + let lower = ItemID(rawValue: Ident.card1) + let upper = ItemID(rawValue: Ident.card1.uppercased()) + #expect(CardStyleSection.target(forCard: lower) == CardStyleSection.target(forCard: upper)) + } +} + +// MARK: - Style ▸ the anchor's geometry + +@Suite("Card sidebar ▸ style editor layout") +struct StyleEditorLayoutTests { + + /// How wide `columns` wells and the gaps between them actually draw. + private func gridWidth(columns: Int) -> CGFloat { + CGFloat(columns) * StyleEditorLayout.wellSide + CGFloat(columns - 1) * StyleEditorLayout.wellSpacing + } + + @Test("The popover anchor is unchanged by the sidebar's arrival") + func thePopoverKeepsItsSettledGeometry() { + // 268 points and 7 + 6 background wells are 03-board-ui.md's own numbers ("narrow enough to + // sit beside a card"), and the two anchors that were here first must not have moved because a + // third one needed different ones. + #expect(StyleEditorLayout.popover.width == 268) + #expect(StyleEditorLayout.popover.padding == 14) + #expect(StyleEditorLayout.popover.backgroundColumns == 7) + #expect(StyleEditorLayout.popover.symbolColumns == 8) + #expect(StyleEditorLayout.popover.symbolGridMaximumHeight == 168) + } + + @Test("The sidebar anchor takes the column it is given and adds nothing to it") + func theSidebarBringsNoGeometryOfItsOwn() { + let layout = StyleEditorLayout.sidebar(contentWidth: 169) + + // No width: the sidebar's is `CardWindowMetrics`' one decision. No padding: the section stack + // is already inset by a gutter, and insetting twice would narrow the grids for nothing. + #expect(layout.width == nil) + #expect(layout.padding == 0) + // No inner scroller: the sidebar is already a scroll view, and a scroll view inside a scroll + // view is a scroll view that fights (`CardWindowView`'s rule). + #expect(layout.symbolGridMaximumHeight == nil) + } + + @Test("The grids fit the sidebar at every text size, and waste no room doing it") + func theGridsFitTheSidebar() { + for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] { + let available = CardWindowMetrics.sidebarContentWidth(bodyPointSize: size) + let layout = StyleEditorLayout.sidebar(contentWidth: available) + + #expect(layout.backgroundColumns == layout.symbolColumns, "one column count for one column") + // Fits — a grid wider than its column puts the last well of every row out of reach of + // the pointer, at a text size nobody checks by eye. + #expect(gridWidth(columns: layout.backgroundColumns) <= available, "overflows at \(size)pt") + // And is maximal: one more well would not have fitted, so the wells are as large a set as + // the column can show rather than an arbitrary count that happened to be safe. + #expect(gridWidth(columns: layout.backgroundColumns + 1) > available, "under-packed at \(size)pt") + } + } + + @Test("The sidebar's grids are narrower than the popover's, at the standard text size") + func theSidebarIsTheNarrowerAnchor() { + // Which is the entire reason this type exists: the popover's 7 wells across do not fit a + // 26-character column, so an editor with one hard-coded frame could not have both anchors. + let layout = StyleEditorLayout.sidebar(contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: 13)) + #expect(layout.backgroundColumns < StyleEditorLayout.popover.backgroundColumns) + #expect(layout.backgroundColumns >= 4, "a grid this narrow would be a strip, not a palette") + } + + @Test("A column too narrow for even one well still asks for one") + func theFitRuleIsTotal() { + // Total over any width, because the caller is a layout system: a zero proposal during a + // window's first frame must not produce a grid of zero columns, which is a division by zero + // waiting in `LazyVGrid`. + #expect(StyleEditorLayout.columns(fitting: 0) == 1) + #expect(StyleEditorLayout.columns(fitting: -100) == 1) + #expect(StyleEditorLayout.columns(fitting: StyleEditorLayout.wellSide) == 1) + } + + @Test("The sidebar's content width is the column minus its two gutters") + func theContentWidthIsTheColumnMinusItsGutters() { + for size in [11.0, 13.0, 24.0] as [CGFloat] { + #expect( + CardWindowMetrics.sidebarContentWidth(bodyPointSize: size) + == CardWindowMetrics.sidebarWidth(bodyPointSize: size) + - 2 * CardWindowMetrics.gutter(bodyPointSize: size) + ) + } + // 26 characters at half an em: 26 × 0.5 × 16 = 208, the text width the column was sized for. + #expect(CardWindowMetrics.sidebarContentWidth(bodyPointSize: 16) == 208) + } +} diff --git a/KanbanTests/TrashWriteTests.swift b/KanbanTests/TrashWriteTests.swift index c31d308..a08a946 100644 --- a/KanbanTests/TrashWriteTests.swift +++ b/KanbanTests/TrashWriteTests.swift @@ -138,6 +138,50 @@ struct TrashDeleteTests { #expect(store.selection.isEmpty) } + @Test("The card window's Delete is the same tombstone, and says nothing about the selection") + func cardWindowDeleteIsTheSameWrite() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") + // Something *else* is selected on the board, which is the case the rule is about: the card + // window's card need not be the board's selection at all. + store.select([card3], liveness: .live, anchor: card3, head: card3) + + store.deleteCard(card1) + + // Byte-indistinguishable from the ⌫ tombstone above — same write op, same stamps, same + // minimal touch (05-card-window.md ▸ Actions: "Delete — tombstones the card"). + let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") + #expect(try FrontmatterDocument.parse(after).deleted.value != nil) + #expect(!after.contains("modified-by")) + #expect(untouchedLines(after) == untouchedLines(before)) + // ⌫ moves the selection to the successor sibling so a repeated keystroke walks down a lane. + // A button in another window has no such continuation, and re-pointing a selection that never + // lost anything would be the drag's mistake (`deleteByDrag`'s rule, shared). + #expect(store.selection.ids == [card3]) + } + + @Test("The card window's Delete writes nothing for a card that is already gone") + func cardWindowDeleteIsLiveOnly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let trashed = try stat(fixture, "\(Ident.lane1)/\(Ident.card2)") + let underTombstonedLane = try stat(fixture, "\(Ident.lane3)/\(Ident.card4)") + + // Its own tombstone, its lane's tombstone (effective liveness is ancestor-walked), and a card + // this board has never heard of. All three are windows already dismissing — nothing is ever + // written into a vanished folder. + store.deleteCard(card2) + store.deleteCard(card4) + store.deleteCard(ItemID(rawValue: "00000000-0000-4000-8000-000000000000")) + + #expect(try stat(fixture, "\(Ident.lane1)/\(Ident.card2)").modified == trashed.modified) + #expect(try stat(fixture, "\(Ident.lane3)/\(Ident.card4)").modified == underTombstonedLane.modified) + #expect(store.banners.oneShots.isEmpty) + } + @Test("Already-tombstoned ids are skipped rather than re-stamped, and an empty set writes nothing") func liveOnly() throws { let fixture = try makeBoard()