The sidebar's Actions section retires — Delete and Reveal in Finder move to the card window's toolbar

Actions is gone from the trailing sidebar. Its two rows land on the card
window's toolbar instead: Delete Card is a new default item (trash SF
Symbol), and Reveal in Finder joins the customizable catalog. The sidebar's
final order is now Style, Details, Attachments — no fourth section.

Delete Card is a push button gated by the same read-only predicate the
sidebar button carried, and it fires the identical write
(BoardStore.deleteCard(_:)) — same bracket, same stamps, no confirmation,
matching the delete flow exactly: recovery is the board's trash lane, so
there is nothing here for an alert to guard. It sits behind a trailing
flexibleSpace in the default set, apart from the four creation/view items
ahead of it, the HIG separation Mail.app's own toolbar Delete models —
one-click, no-confirm, recoverable by trash.

Reveal in Finder is catalog-only: it already had a menu-bar twin with no
default chord (File ▸ Reveal in Finder / RevealInFinderCommand), so nothing
was unreachable before this — the toolbar item is Customize's shortcut to
the same computation (CardAttachments.revealURLs), not a new path.

Delete Card needed a menu-row twin of its own before it could sit on the
toolbar at all ("toolbars are pure enhancement: every function they host
already has a menu item + shortcut" — 03-board-ui.md ▸ Toolbar). File ▸
Delete Card is that row: distinctly titled from the board-scope File ▸
Delete (whose title 11-command-nexus.md calls out as the ⌘⌫ chord's
singleton), and deliberately chord-less — an enabled delete-key equivalent
in the card window would steal delete-to-line-start from its text surfaces,
the same reason the board's own ⌘⌫ was never extended here in the first
place. A new small handle, CardWindowActions, carries the wiring through the
focus system the way CardAttachments and CardPrintSubject already do for
their own single-purpose seams — kept separate from CardAttachments on
purpose, since a delete has nothing to do with the attachments section that
type is scoped to.

CardToolbarTests grows the BoardToolbarTests split (defaults vs. catalog,
now that they differ) plus two new suites: Delete Card firing the wired
write under the lock, and Reveal in Finder's enablement mirroring the menu
row's own card-window computation.

05-card-window.md's Actions section and 11-command-nexus.md's File-menu
inventory are now stale; both amendments are owed and tracked on the card's
own thread rather than made here.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 10:37:55 -04:00
parent 2a8fef258d
commit dfc6057f0d
9 changed files with 343 additions and 137 deletions
+40 -4
View File
@@ -289,6 +289,11 @@ struct CardWindowHost: View {
/// needs them (`CardPrintSubject`). Window-scoped for `CardBodyPresentation`'s reason: two card /// needs them (`CardPrintSubject`). Window-scoped for `CardBodyPresentation`'s reason: two card
/// windows are two documents, and the menu bar reaches the frontmost one through the focus system. /// windows are two documents, and the menu bar reaches the frontmost one through the focus system.
@State private var cardPrint = CardPrintSubject() @State private var cardPrint = CardPrintSubject()
/// This window's card-level actions today, just Delete Card, promoted off the sidebar's former
/// Actions section to the toolbar (`CardWindowActions`, `CardToolbar`). Window-scoped for
/// `CardBodyPresentation`'s reason: File Delete Card (`DeleteCardCommand`) reaches the
/// frontmost card window through the focus system exactly as Add Attachment does.
@State private var cardActions = CardWindowActions()
/// This window's thumbnail memory. Held here rather than in the section so it survives every /// This window's thumbnail memory. Held here rather than in the section so it survives every
/// snapshot the store applies a cache that died with the view would regenerate every thumbnail /// snapshot the store applies a cache that died with the view would regenerate every thumbnail
/// on every reload (`AttachmentThumbnailCache`). /// on every reload (`AttachmentThumbnailCache`).
@@ -394,6 +399,10 @@ struct CardWindowHost: View {
// File Print (P) reaches the frontmost card window the same way the card-window scope // File Print (P) reaches the frontmost card window the same way the card-window scope
// of a row the board window answers with its whole board (11-command-nexus.md). // of a row the board window answers with its whole board (11-command-nexus.md).
.focusedSceneValue(\.cardPrint, cardPrint) .focusedSceneValue(\.cardPrint, cardPrint)
// File Delete Card reaches the frontmost card window the same way the toolbar's own
// Delete Card item reads this same handle directly, wired at install time rather than
// through the focus system (`configureWindow`).
.focusedSceneValue(\.cardWindowActions, cardActions)
// The raw-source outlet's detailed alert, presented over this window a validation // The raw-source outlet's detailed alert, presented over this window a validation
// refusal on Apply, or a file that could not be opened as source. It hangs *here* rather // refusal on Apply, or a file that could not be opened as source. It hangs *here* rather
// than inside the editor because the second of those fires while source mode is still // than inside the editor because the second of those fires while source mode is still
@@ -536,6 +545,9 @@ struct CardWindowHost: View {
.onChange(of: store.isReadOnly, initial: true) { _, locked in .onChange(of: store.isReadOnly, initial: true) { _, locked in
attachments.isEditable = !locked attachments.isEditable = !locked
session.comments.isEditable = !locked session.comments.isEditable = !locked
// Delete Card's own read-only gate the sidebar Actions button's `.disabled(store
// .isReadOnly)`, carried over verbatim to its toolbar and menu-row twins.
cardActions.isDeletable = !locked
} }
// **The thread re-reads on every landed reload** (05 The comments column: "the pane // **The thread re-reads on every landed reload** (05 The comments column: "the pane
// reloads its thread from the same FSEvents stream"). // reloads its thread from the same FSEvents stream").
@@ -729,6 +741,24 @@ struct CardWindowHost: View {
attachments, store: store, cardID: cardID, undo: session.undo, clipboard: appModel.clipboard attachments, store: store, cardID: cardID, undo: session.undo, clipboard: appModel.clipboard
) )
Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo) Self.configureComments(session.comments, store: store, cardID: cardID, on: session.undo)
Self.configureActions(cardActions, store: store, cardID: cardID)
}
/// Points this window's card-level actions at its card **the one place File Delete Card and
/// the toolbar's Delete Card item learn which card they act on**, `configureAttachments`'s pattern
/// applied to the seam Actions Delete left behind when the sidebar section retired.
///
/// The store is captured **weakly**, `configureAttachments`'s own reason: a toolbar click or menu
/// row still firing after the board window has gone should write nothing rather than resurrect a
/// released store.
///
/// `static`, and taking every collaborator as a parameter, for `configureAttachments`'s reason:
/// which card a gesture lands on is invisible in a running window until it is wrong, and this
/// shape is what lets a test drive the real wiring rather than a re-typed copy of it.
static func configureActions(_ actions: CardWindowActions, store: BoardStore, cardID: ItemID) {
actions.deleteCard = { [weak store] in
store?.deleteCard(cardID)
}
} }
/// Points this window's session at **its own undo stack** the three seams the two-level model /// Points this window's session at **its own undo stack** the three seams the two-level model
@@ -951,13 +981,19 @@ struct CardWindowHost: View {
// The window's customizable toolbar Edit Body · Raw Source · Add Attachment, "the // The window's customizable toolbar Edit Body · Raw Source · Add Attachment, "the
// window's three committed functions" (03-board-ui.md Toolbar; 05-card-window.md // window's three committed functions" (03-board-ui.md Toolbar; 05-card-window.md
// Window), joined by Show Sidebar (below). It carries the three window-scoped handles above // Window), joined by Show Sidebar and, since the sidebar's Actions section retired, Delete
// rather than a store, which is why it is installed here and not at attach: those are this // Card and Reveal in Finder (below). It carries the four window-scoped handles above rather
// window's, and so is it. Show Sidebar needs none of them its read and write default to // than a store, which is why it is installed here and not at attach: those are this window's,
// and so is it. Show Sidebar needs none of them its read and write default to
// `AppPreferences.showCardSidebar` / `.setShowCardSidebar`, the same app-wide bit every card // `AppPreferences.showCardSidebar` / `.setShowCardSidebar`, the same app-wide bit every card
// window answers to, so this call leaves the two injectable parameters unnamed. // window answers to, so this call leaves the two injectable parameters unnamed.
windowController.installToolbar( windowController.installToolbar(
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments) CardToolbar.controller(
body: bodyPresentation,
rawSource: rawSource,
attachments: attachments,
actions: cardActions
)
) )
// **No title in the title bar** the card's name is shown as part of the card's body // **No title in the title bar** the card's name is shown as part of the card's body
+8
View File
@@ -236,6 +236,14 @@ struct KanbanApp: App {
SetAsHeroCommand() SetAsHeroCommand()
RemoveHeroCommand() RemoveHeroCommand()
AddCommentCommand() AddCommentCommand()
// **Delete Card joined 2026-08-09** (05-card-window.md Actions, retired Pipeline card
// bcd3b323): the card window's own delete, promoted off the sidebar's Actions section to
// a menu row so the new toolbar item mirroring it (`CardToolbar`) has the menu twin every
// toolbar function needs. Distinctly titled from the row below "Delete" is that row's
// own singleton title (11-command-nexus.md) and deliberately chord-less: an enabled
// delete-key equivalent here would steal delete-to-line-start from this window's text
// surfaces, the reason `TrashCommands`' own was never extended to the card window.
DeleteCardCommand()
Divider() Divider()
+4 -2
View File
@@ -4513,8 +4513,10 @@ public final class BoardStore: HealHost {
_ = moveLanesToTrash(lanes) _ = moveLanesToTrash(lanes)
} }
/// **The card window's Actions Delete** (05-card-window.md Actions: "Delete moves the card /// **The card window's own delete** the sidebar's Actions Delete button until 2026-08-09
/// to the trash the window then dismisses itself"). /// (Pipeline card bcd3b323), now the window's toolbar item and File Delete Card
/// (`CardWindowActions`, `CardToolbar`) moving the card to the trash; the window then dismisses
/// itself (05-card-window.md Actions, Deletion & lifecycle).
/// ///
/// The write is `moveToTrash(_:)`, so a card deleted from its own window is byte-indistinguishable /// The write is `moveToTrash(_:)`, so a card deleted from its own window is byte-indistinguishable
/// from one deleted with on the board or dropped on the trash column. What differs is the same /// from one deleted with on the board or dropped on the trash column. What differs is the same
-72
View File
@@ -256,75 +256,3 @@ struct CardStyleSection: View {
) )
} }
} }
// MARK: - Actions
/// The sidebar's **Actions** section, at the bottom of the stack (05-card-window.md Actions):
/// **Delete** moves the card to the trash 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 delete exactly (same write op, same
/// bracket, same stamps). It does not close this window: the card's move into `.trash/` 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 ("moves the card to the trash; the window then dismisses itself").
///
/// Recovery is the board's trash column, which is why this needs no confirmation: the card is still
/// there to drag out or cut and paste back (03-board-ui.md § Trash there is no Put Back), and 03
/// 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)
}
}
+82 -9
View File
@@ -7,6 +7,8 @@ extension NSToolbarItem.Identifier {
static let cardRawSource = Self("card.rawSource") static let cardRawSource = Self("card.rawSource")
static let cardAddAttachment = Self("card.addAttachment") static let cardAddAttachment = Self("card.addAttachment")
static let cardShowSidebar = Self("card.showSidebar") static let cardShowSidebar = Self("card.showSidebar")
static let cardDeleteCard = Self("card.deleteCard")
static let cardRevealInFinder = Self("card.revealInFinder")
} }
// MARK: - The card window's toolbar // MARK: - The card window's toolbar
@@ -14,14 +16,19 @@ extension NSToolbarItem.Identifier {
/// The card window's toolbar (03-board-ui.md Toolbar; 05-card-window.md Window). /// The card window's toolbar (03-board-ui.md Toolbar; 05-card-window.md Window).
/// ///
/// "**Card window default: Edit Body · Raw Source · Add Attachment** the window's three committed /// "**Card window default: Edit Body · Raw Source · Add Attachment** the window's three committed
/// functions, all discoverable from its toolbar; the catalog is the same trio." So the default set /// functions, all discoverable from its toolbar." **Show Sidebar joined them** (05-card-window.md
/// *is* the catalog here, and Customize offers rearrangement and removal rather than a choice of /// Composition, the toggle added beside the toolbar-customization work): a fourth default item, the
/// items which is exactly what a window with three functions should offer. **Show Sidebar joined /// trailing sidebar's own show/hide control, the `Show Trash` precedent applied to the card window's
/// them** (05-card-window.md Composition, the toggle added beside the toolbar-customization work): /// one collapsible pane. **Delete Card joined them too** (05 Actions, retired Pipeline card
/// a fourth default item, the trailing sidebar's own show/hide control, the `Show Trash` precedent /// bcd3b323): the sidebar's Actions section is gone, and its destructive Delete moved here as the
/// applied to the card window's one collapsible pane. /// fifth default item, trailing a `.flexibleSpace` so it sits apart from the four creation-and-view
/// items ahead of it HIG's "destructive actions read as separate from the everyday cluster",
/// `Mail.app`'s own toolbar Delete being the nearest system precedent for a one-click, no-confirm,
/// recoverable-by-trash button. **Reveal in Finder joined the catalog** the same day, catalog-only
/// it already has a menu-bar twin with no default chord (`RevealInFinderCommand`), so nothing was
/// unreachable before this; the toolbar item is Customize's shortcut to it, not its only path.
/// ///
/// ### The four items are the four menu rows, predicates included /// ### The six items are six menu rows, predicates included
/// ///
/// - **Edit Body** is "a single toggle button (on-state in Edit mirroring the View Edit Body /// - **Edit Body** is "a single toggle button (on-state in Edit mirroring the View Edit Body
/// checkmark)", and it disables while source mode is active. That clause is not restated here: the /// checkmark)", and it disables while source mode is active. That clause is not restated here: the
@@ -41,6 +48,16 @@ extension NSToolbarItem.Identifier {
/// app-wide, persisted bit `CardWindowView` renders from and every card window's toolbar answers /// app-wide, persisted bit `CardWindowView` renders from and every card window's toolbar answers
/// to alike. Always enabled, `ShowCommentsCommand`'s own posture: showing or hiding a pane is not a /// to alike. Always enabled, `ShowCommentsCommand`'s own posture: showing or hiding a pane is not a
/// mutation, so the read-only lock has no say in it. /// mutation, so the read-only lock has no say in it.
/// - **Delete Card** is a push button carrying `trash`, mirroring **File Delete Card**
/// (`DeleteCardCommand`) a new, distinctly titled row (never "Delete": 11-command-nexus.md files
/// that title as the board-scope row's own singleton). Its predicate is the sidebar button's own
/// read-only check (`CardWindowActions.isDeletable`), and firing it calls `BoardStore.deleteCard(_:)`
/// directly the same write, same bracket, same stamps and the sidebar button always used. **No
/// confirmation**, matching the delete flow it replaces exactly: recovery is the board's trash lane,
/// so the alert 03 reserves is for the permanent purge, not this move.
/// - **Reveal in Finder** mirrors the existing card-window branch of `RevealInFinderCommand` same
/// computation (`CardAttachments.revealURLs`), same `NSWorkspace` call, so the toolbar item and the
/// menu row can never disagree about what "the selected attachment, or the card's folder" means.
/// ///
/// Labels are the menu titles minus a trailing ellipsis, so File Add Attachment labels as **Add /// Labels are the menu titles minus a trailing ellipsis, so File Add Attachment labels as **Add
/// Attachment** (03's own example). /// Attachment** (03's own example).
@@ -49,16 +66,24 @@ enum CardToolbar {
static let identifier = "dev.rzen.indie.Kanban.card" static let identifier = "dev.rzen.indie.Kanban.card"
/// "The catalog is the same trio" plus Show Sidebar so the defaults are the whole catalog, in /// The trio plus Show Sidebar plus Delete Card the whole catalog except Reveal in Finder, which
/// this file's order. /// is catalog-only (already reachable off its own menu row, no default chord). The trailing
/// `.flexibleSpace` is what "placed apart from the creation-side defaults" means in code: Delete
/// Card is still a default, just not adjacent to the four items ahead of it.
static let defaultItems: [NSToolbarItem.Identifier] = [ static let defaultItems: [NSToolbarItem.Identifier] = [
.cardEditBody, .cardEditBody,
.cardRawSource, .cardRawSource,
.cardAddAttachment, .cardAddAttachment,
.cardShowSidebar, .cardShowSidebar,
.flexibleSpace,
.cardDeleteCard,
] ]
/// - Parameters: /// - Parameters:
/// - actions: Delete Card's window-scoped handle (`CardWindowActions`) the same object
/// `DeleteCardCommand` reads through the focus system, handed here directly since the toolbar
/// is installed once at window-open time rather than reactively (`CardWindowHost
/// .configureWindow`).
/// - isSidebarShown: Show Sidebar's read, defaulted to the real bit /// - isSidebarShown: Show Sidebar's read, defaulted to the real bit
/// (`AppPreferences.showCardSidebar`). Injectable so a test can drive the item without /// (`AppPreferences.showCardSidebar`). Injectable so a test can drive the item without
/// touching the developer's own `UserDefaults.standard` domain `StyleRecents`' own caution, /// touching the developer's own `UserDefaults.standard` domain `StyleRecents`' own caution,
@@ -69,6 +94,7 @@ enum CardToolbar {
body: CardBodyPresentation, body: CardBodyPresentation,
rawSource: CardRawSourceSession, rawSource: CardRawSourceSession,
attachments: CardAttachments, attachments: CardAttachments,
actions: CardWindowActions,
isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar }, isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar },
setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) } setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) }
) -> [ToolbarItemSpec] { ) -> [ToolbarItemSpec] {
@@ -124,13 +150,59 @@ enum CardToolbar {
symbol: "sidebar.right", symbol: "sidebar.right",
behavior: .toggle(isEnabled: { true }, isOn: isSidebarShown, setOn: setSidebarShown) behavior: .toggle(isEnabled: { true }, isOn: isSidebarShown, setOn: setSidebarShown)
), ),
// **The sidebar's former Actions Delete** (05 Actions, retired) same write, same
// no-confirmation posture: recovery is the board's trash lane, so there is nothing here
// for an alert to guard. Placed last, after the trailing `.flexibleSpace` in
// `defaultItems`, so it never sits adjacent to the four creation/view items ahead of it.
.mirroring(
menuTitle: "Delete Card",
identifier: .cardDeleteCard,
symbol: "trash",
behavior: .button(
isEnabled: { [weak actions] in DeleteCardCommand.isEnabled(actions) },
perform: { [weak actions] in actions?.deleteCard?() }
)
),
// **The sidebar's former Actions Reveal in Finder** (05 Actions, retired) catalog
// only, since it already has a menu-bar twin with no default chord
// (`RevealInFinderCommand`); Customize is this button's whole reason to exist, not its
// only path. The computation is that row's card-window branch, verbatim, so the two
// surfaces can never disagree about what "the selected attachment, or the card's folder"
// means.
.mirroring(
menuTitle: "Reveal in Finder",
identifier: .cardRevealInFinder,
symbol: "folder",
behavior: .button(
isEnabled: { [weak attachments] in
guard let attachments else { return false }
return !Self.revealURLs(attachments).isEmpty
},
perform: { [weak attachments] in
guard let attachments else { return }
NSWorkspace.shared.activateFileViewerSelecting(Self.revealURLs(attachments))
}
)
),
] ]
} }
/// `RevealInFinderCommand`'s own card-window branch, read here rather than re-derived the
/// selected attachment's file when the section has the keyboard, the card's folder otherwise
/// (`CardAttachments.revealURLs`'s own rule).
private static func revealURLs(_ attachments: CardAttachments) -> [URL] {
CardAttachments.revealURLs(
cardFolder: attachments.cardFolder,
selectedURL: attachments.selectedURL,
isSectionFocused: attachments.isFocused
)
}
static func controller( static func controller(
body: CardBodyPresentation, body: CardBodyPresentation,
rawSource: CardRawSourceSession, rawSource: CardRawSourceSession,
attachments: CardAttachments, attachments: CardAttachments,
actions: CardWindowActions,
isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar }, isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar },
setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) } setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) }
) -> WindowToolbarController { ) -> WindowToolbarController {
@@ -140,6 +212,7 @@ enum CardToolbar {
body: body, body: body,
rawSource: rawSource, rawSource: rawSource,
attachments: attachments, attachments: attachments,
actions: actions,
isSidebarShown: isSidebarShown, isSidebarShown: isSidebarShown,
setSidebarShown: setSidebarShown setSidebarShown: setSidebarShown
), ),
+81
View File
@@ -0,0 +1,81 @@
import SwiftUI
// MARK: - The window's card-level actions, as a handle
/// One card window's card-level actions, reduced to what things *outside* it need today, exactly
/// one: the write that used to be the sidebar's Actions Delete (05-card-window.md Actions, retired
/// with the Actions section Pipeline card bcd3b323), now the card window's own toolbar item
/// (`CardToolbar`) and its required menu twin (`DeleteCardCommand`).
///
/// `CardAttachments`'s shape and for its own reason: published through the focus system so a menu
/// item and a toolbar item can reach the frontmost card window without a which-window-is-key
/// register, `@State` in the host so it dies with the window it belongs to.
///
/// **Deliberately not folded into `CardAttachments`.** That type is "reduced to what things outside
/// it need" about the *attachments section* specifically reused elsewhere only as the "a card
/// window is frontmost" presence signal (`ShowSidebarCommand`'s own note) and a delete has nothing
/// to do with attachments. Folding it in would leave `CardAttachments`'s own doc comment wrong about
/// what the type is for.
@MainActor
@Observable
public final class CardWindowActions {
/// Whether the delete write is offered at all `!store.isReadOnly`, the read-only lock applied
/// to the one mutation this handle starts (02-architecture.md's every-entry-point predicate,
/// `CardActionsSection`'s own posture before it retired).
public var isDeletable = false
/// Moves this window's card to the trash filled by the host with `BoardStore.deleteCard(_:)`,
/// the same write and the trash column's own drop already make (`CardActionsSection`'s former
/// doc comment, carried over verbatim: "the delete exactly same write op, same bracket, same
/// stamps"). It does not dismiss the window; the window's fate is re-derived from every snapshot,
/// exactly as it was when this lived in the sidebar.
public var deleteCard: (() -> Void)?
public init() {}
}
/// The focused card window's actions handle `FocusedCardAttachmentsKey`'s own shape, one type over.
struct FocusedCardWindowActionsKey: FocusedValueKey {
typealias Value = CardWindowActions
}
extension FocusedValues {
var cardWindowActions: CardWindowActions? {
get { self[FocusedCardWindowActionsKey.self] }
set { self[FocusedCardWindowActionsKey.self] = newValue }
}
}
// MARK: - File Delete Card
/// The card window's delete, as a menu row the required twin `CardToolbar`'s own item needs
/// ("toolbars are pure enhancement: every function they host already has a menu item + shortcut",
/// 03-board-ui.md Toolbar) now that the sidebar's Actions Delete button has retired.
///
/// **Deliberately titled "Delete Card", not "Delete"** 11-command-nexus.md's File Delete row is
/// explicit that the chord's title is a singleton ("the chord's only owner no twin menu items, no
/// shared-equivalent routing"), and that row is unchanged: it still validates against the board
/// window's own focused store and still carries . This is a second, distinctly named row, scoped to
/// the card window alone, so the two can never be mistaken for one another in the File menu.
///
/// **No default chord, on purpose** the reason the sidebar button existed in the first place and
/// the reason `TrashCommands`' own was "deliberately not extended to the card window": an enabled
/// delete-key equivalent here would steal delete-to-line-start from this window's text surfaces (the
/// body editor, the title field). `View History`'s own "no default chord" is the precedent for a
/// menu row that means something without owning a key.
struct DeleteCardCommand: View {
@FocusedValue(\.cardWindowActions) private var actions
static func isEnabled(_ actions: CardWindowActions?) -> Bool {
actions?.isDeletable == true
}
var body: some View {
Button("Delete Card") {
actions?.deleteCard?()
}
.disabled(!Self.isEnabled(actions))
}
}
+23 -21
View File
@@ -69,12 +69,14 @@ struct CardWindowView: View {
/// The board this card belongs to. /// 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 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 /// the Style section is why: it hosts the **shared** style editor, whose API is store-shaped by
/// store-shaped by design (it reads the target set's current values and writes through the one /// design (it reads the target set's current values and writes through the one `applyStyle`
/// `applyStyle` bracket every anchor shares), and the Actions section's Delete is the store's own /// bracket every anchor shares). Routing it through a closure of this window's own would be a
/// move-to-trash. Routing either through a closure of this window's own would be a second /// second card-styling path to keep in step with the first exactly what "one component, one
/// card-styling or card-deleting path to keep in step with the first exactly what "one /// behavior" forbids. Delete moved off the sidebar's own Actions section and onto the card
/// component, one behavior" and "exactly the delete" forbid. /// window's toolbar (`CardToolbar`, `CardWindowActions`) still the store's own move-to-trash,
/// still "exactly the delete", just reached through the host's toolbar wiring rather than this
/// view.
let store: BoardStore let store: BoardStore
/// The app-wide quick-style recents the embedded editor feeds (03-board-ui.md Styling /// 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 /// Controls) app state, not board state, which is why it arrives beside the store rather than
@@ -398,21 +400,23 @@ struct CardWindowView: View {
// MARK: - Attributes sidebar // MARK: - Attributes sidebar
/// The sidebar's sections, **reordered 2026-08-09** (Pipeline card 8f26b029) so **Attachments /// The sidebar's sections: **Style, Details, Attachments** Attachments at the bottom of the
/// sits at the bottom of the stack**: Style, Details, Actions, Attachments. Section internals are /// stack (reordered 2026-08-09, Pipeline card 8f26b029), Actions gone entirely (retired the same
/// untouched this is ordering only, and it is exactly the `VStack`'s child order, so keyboard /// day, Pipeline card bcd3b323): its Delete and Reveal in Finder are now the card window's own
/// Tab order and VoiceOver's reading order follow it for free; A (`AddAttachmentCommand`) /// toolbar items (`CardToolbar`), reachable from Customize and, for Delete, on by default. This
/// still opens the same file panel regardless of where the section sits in the stack, since it /// is exactly the `VStack`'s child order, so keyboard Tab order and VoiceOver's reading order
/// reaches the window through the focus system rather than through this view's layout. /// follow it for free; A (`AddAttachmentCommand`) still opens the same file panel regardless of
/// where Attachments sits in the stack, since it reaches the window through the focus system
/// rather than through this view's layout.
/// ///
/// 05-card-window.md The attributes sidebar still documents the pathfinder's original order /// 05-card-window.md The attributes sidebar still documents the pathfinder's four-section order
/// (Attachments, Style, Details, Actions) an owed amendment, tracked on that card's own thread /// with Actions at the bottom an owed amendment, tracked on the two cards' own threads rather
/// rather than made here. /// than made here.
/// ///
/// One of the four is conditional, and the condition is the section's own rather than a rule /// One of the two remaining sections is conditional, and the condition is the section's own
/// restated here: **Details** renders nothing when the card carries no unknown frontmatter keys /// rather than a rule restated here: **Details** renders nothing when the card carries no unknown
/// ("shown only when any exist"). Everything else in the stack is unconditional, so the /// frontmatter keys ("shown only when any exist"). Style and Attachments are unconditional, so
/// composition a user learns on one card is the composition they get on the next. /// the composition a user learns on one card is the composition they get on the next.
/// ///
/// The History section that once sat between Details and Actions left with app-managed git /// The History section that once sat between Details and Actions left with app-managed git
/// (strategy/01-git-excision.md, 2026-08-08); View History (`FutureCommands.swift`) is the only /// (strategy/01-git-excision.md, 2026-08-08); View History (`FutureCommands.swift`) is the only
@@ -426,8 +430,6 @@ struct CardWindowView: View {
// keys and their order included, and `Card` has carried it since (`BoardModel`). // keys and their order included, and `Card` has carried it since (`BoardModel`).
CardDetailsSection(rows: CardDetails.rows(of: card.document)) CardDetailsSection(rows: CardDetails.rows(of: card.document))
CardActionsSection(store: store, cardID: card.id, cardFolder: cardFolder)
CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails) CardAttachmentsSection(attachments: attachments, thumbnails: thumbnails)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
+3 -2
View File
@@ -404,8 +404,9 @@ struct CardSessionCloseTests {
let original = try body(fixture, cardPath) let original = try body(fixture, cardPath)
editBody(window, to: "Edited.\n") editBody(window, to: "Edited.\n")
// The card window's own Actions Delete a board gesture, on the board's stack, which // The card window's own delete (its toolbar item and File Delete Card since 2026-08-09,
// dismisses this window (05-card-window.md Deletion & lifecycle). // the sidebar's Actions Delete button before it) a board gesture, on the board's stack,
// which dismisses this window (05-card-window.md Deletion & lifecycle).
window.store.deleteCard(cardID) window.store.deleteCard(cardID)
window.store.handleWatcherEvent(.treeChanged(.appMediated)) window.store.handleWatcherEvent(.treeChanged(.appMediated))
await window.store.awaitQuiescence() await window.store.awaitQuiescence()
+102 -27
View File
@@ -580,14 +580,15 @@ struct BoardToolbarTests {
// MARK: - The card window's toolbar // MARK: - The card window's toolbar
/// The card toolbar's trio, and the two state clauses 03 states about it: Edit Body's on-state and /// The card toolbar's six items: the trio's two state clauses (Edit Body's on-state and its
/// its raw-source disable, and Add Attachment staying live in every mode. /// raw-source disable, Add Attachment staying live in every mode), Show Sidebar, and since the
/// sidebar's Actions section retired (Pipeline card bcd3b323) Delete Card and Reveal in Finder.
@MainActor @MainActor
@Suite("Toolbar ▸ the card window") @Suite("Toolbar ▸ the card window")
struct CardToolbarTests { struct CardToolbarTests {
/// The three window-scoped handles a card window's toolbar reads, wired as the host wires them. /// The four window-scoped handles a card window's toolbar reads, wired as the host wires them.
private func makeHandles() -> (CardBodyPresentation, CardRawSourceSession, CardAttachments) { private func makeHandles() -> (CardBodyPresentation, CardRawSourceSession, CardAttachments, CardWindowActions) {
let body = CardBodyPresentation() let body = CardBodyPresentation()
let raw = CardRawSourceSession() let raw = CardRawSourceSession()
raw.read = { .read("---\nschema: 1\norder: 1\n---\nbody\n") } raw.read = { .read("---\nschema: 1\norder: 1\n---\nbody\n") }
@@ -595,40 +596,62 @@ struct CardToolbarTests {
let attachments = CardAttachments() let attachments = CardAttachments()
attachments.isEditable = true attachments.isEditable = true
attachments.cardFolder = URL(filePath: "/tmp/board/lane/card") attachments.cardFolder = URL(filePath: "/tmp/board/lane/card")
return (body, raw, attachments) let actions = CardWindowActions()
actions.isDeletable = true
return (body, raw, attachments, actions)
} }
@Test("The default set is the whole catalog — the trio plus Show Sidebar, in this file's order") @Test("The default set is the trio, Show Sidebar, and Delete Card — separated by a flexible space")
func defaultsAreTheCatalog() { func defaultsAreSeparatedFromDeleteCard() {
let (body, raw, attachments) = makeHandles() // "Card window default: Edit Body · Raw Source · Add Attachment" joined by Show Sidebar (the
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) // toolbar-toggle card) and now Delete Card, HIG's "apart from the creation-side defaults"
// spelled as a literal `.flexibleSpace` between the two clusters `BoardToolbar.defaultItems`'
// own leading-spacer pattern, turned trailing here.
#expect(CardToolbar.defaultItems == [
.cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar, .flexibleSpace, .cardDeleteCard,
])
#expect(CardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [
.cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar, .cardDeleteCard,
])
}
// "Card window default: Edit Body · Raw Source · Add Attachment the catalog is the same @Test("Reveal in Finder is catalog-only — it already has a menu row with no default chord")
// trio" joined by Show Sidebar, the fourth default item the toolbar-toggle card added. func revealInFinderIsCatalogOnly() {
#expect(CardToolbar.defaultItems == [.cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar]) #expect(!CardToolbar.defaultItems.contains(.cardRevealInFinder))
#expect(specs.map(\.identifier) == CardToolbar.defaultItems) }
#expect(specs.map(\.label) == ["Edit Body", "Raw Source", "Add Attachment", "Show Sidebar"])
@Test("The catalog is the six items, in this file's order, labeled off their menu titles")
func catalogIsTheSixItems() {
let (body, raw, attachments, actions) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
#expect(specs.map(\.identifier) == [
.cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar, .cardDeleteCard, .cardRevealInFinder,
])
#expect(specs.map(\.label) == [
"Edit Body", "Raw Source", "Add Attachment", "Show Sidebar", "Delete Card", "Reveal in Finder",
])
} }
@Test("Every item's symbol resolves on this system") @Test("Every item's symbol resolves on this system")
func symbolsResolve() { func symbolsResolve() {
let (body, raw, attachments) = makeHandles() let (body, raw, attachments, actions) = makeHandles()
for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) { for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) {
guard let symbol = spec.symbol else { continue } guard let symbol = spec.symbol else { continue }
#expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have") #expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have")
} }
} }
@Test("Every item of the trio actually builds, and the toolbar is customizable") @Test("Every catalog item actually builds, and the toolbar is customizable")
func everyItemBuilds() throws { func everyItemBuilds() throws {
let (body, raw, attachments) = makeHandles() let (body, raw, attachments, actions) = makeHandles()
let controller = CardToolbar.controller(body: body, rawSource: raw, attachments: attachments) let controller = CardToolbar.controller(body: body, rawSource: raw, attachments: attachments, actions: actions)
#expect(controller.toolbar.allowsUserCustomization) #expect(controller.toolbar.allowsUserCustomization)
#expect(controller.toolbar.allowsDisplayModeCustomization) #expect(controller.toolbar.allowsDisplayModeCustomization)
#expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == CardToolbar.defaultItems) #expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == CardToolbar.defaultItems)
for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) { for spec in CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions) {
let item = try #require(controller.toolbar( let item = try #require(controller.toolbar(
controller.toolbar, controller.toolbar,
itemForItemIdentifier: spec.identifier, itemForItemIdentifier: spec.identifier,
@@ -641,8 +664,8 @@ struct CardToolbarTests {
@Test("Edit Body is a single toggle showing on-state in Edit") @Test("Edit Body is a single toggle showing on-state in Edit")
func editBodyShowsItsMode() { func editBodyShowsItsMode() {
let (body, raw, attachments) = makeHandles() let (body, raw, attachments, actions) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
guard let editBody = specs.spec(.cardEditBody) else { guard let editBody = specs.spec(.cardEditBody) else {
Issue.record("no Edit Body item") Issue.record("no Edit Body item")
return return
@@ -661,8 +684,8 @@ struct CardToolbarTests {
@Test("Raw Source active disables Edit Body — the row's own predicate, mirrored") @Test("Raw Source active disables Edit Body — the row's own predicate, mirrored")
func rawSourceDisablesEditBody() { func rawSourceDisablesEditBody() {
let (body, raw, attachments) = makeHandles() let (body, raw, attachments, actions) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
guard let editBody = specs.spec(.cardEditBody), let rawSource = specs.spec(.cardRawSource) else { guard let editBody = specs.spec(.cardEditBody), let rawSource = specs.spec(.cardRawSource) else {
Issue.record("the card toolbar is missing an item") Issue.record("the card toolbar is missing an item")
return return
@@ -687,8 +710,8 @@ struct CardToolbarTests {
@Test("Add Attachment stays enabled in every mode, including an open raw edit") @Test("Add Attachment stays enabled in every mode, including an open raw edit")
func addAttachmentIsAlwaysAvailable() { func addAttachmentIsAlwaysAvailable() {
let (body, raw, attachments) = makeHandles() let (body, raw, attachments, actions) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments) let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
guard let addAttachment = specs.spec(.cardAddAttachment) else { guard let addAttachment = specs.spec(.cardAddAttachment) else {
Issue.record("no Add Attachment item") Issue.record("no Add Attachment item")
return return
@@ -719,12 +742,13 @@ struct CardToolbarTests {
/// item here with no window-scoped handle to hold a scratch value instead). /// item here with no window-scoped handle to hold a scratch value instead).
@Test("Show Sidebar toggles its own bit, always enabled, on-state matching the read") @Test("Show Sidebar toggles its own bit, always enabled, on-state matching the read")
func showSidebarTogglesItsOwnBit() { func showSidebarTogglesItsOwnBit() {
let (body, raw, attachments) = makeHandles() let (body, raw, attachments, actions) = makeHandles()
var shown = true var shown = true
let specs = CardToolbar.specs( let specs = CardToolbar.specs(
body: body, body: body,
rawSource: raw, rawSource: raw,
attachments: attachments, attachments: attachments,
actions: actions,
isSidebarShown: { shown }, isSidebarShown: { shown },
setSidebarShown: { shown = $0 } setSidebarShown: { shown = $0 }
) )
@@ -750,6 +774,57 @@ struct CardToolbarTests {
attachments.isEditable = false attachments.isEditable = false
#expect(showSidebar.isEnabled) #expect(showSidebar.isEnabled)
} }
/// **Delete Card is the sidebar Actions button's former write, on a push button** gated by the
/// same read-only predicate (`CardWindowActions.isDeletable`, wired from `!store.isReadOnly`
/// exactly as the sidebar button's `.disabled(store.isReadOnly)` was), and firing calls the same
/// closure the host wires from `BoardStore.deleteCard(_:)` (`CardWindowHost.configureActions`).
@Test("Delete Card is a push button, gated by the read-only lock, that fires the wired delete")
func deleteCardFiresTheWiredDelete() {
let (body, raw, attachments, actions) = makeHandles()
var deleted = 0
actions.deleteCard = { deleted += 1 }
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
guard let deleteCard = specs.spec(.cardDeleteCard) else {
Issue.record("no Delete Card item")
return
}
#expect(deleteCard.isOn == nil, "Delete Card is a push button, not a toggle")
#expect(deleteCard.isEnabled)
deleteCard.activate()
#expect(deleted == 1, "the item fires the same write the host wires from BoardStore.deleteCard")
// The read-only lock is the sidebar button's own predicate, carried over whole.
actions.isDeletable = false
#expect(!deleteCard.isEnabled)
#expect(deleteCard.isEnabled == DeleteCardCommand.isEnabled(actions))
}
/// **Reveal in Finder computes exactly what `RevealInFinderCommand`'s card-window branch does**
/// same function (`CardAttachments.revealURLs`), so the two surfaces can never disagree. Firing it
/// is not exercised here, `addAttachmentIsAlwaysAvailable`'s own restraint: both open a real
/// system surface (a panel there, Finder here), which a unit test does not drive.
@Test("Reveal in Finder is enabled exactly when the row's own computation finds something to reveal")
func revealInFinderMirrorsTheRowsComputation() {
let (body, raw, attachments, actions) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments, actions: actions)
guard let reveal = specs.spec(.cardRevealInFinder) else {
Issue.record("no Reveal in Finder item")
return
}
// `makeHandles()` gives the section a folder and no focus the card-folder branch.
#expect(reveal.isEnabled)
#expect(!CardAttachments.revealURLs(
cardFolder: attachments.cardFolder, selectedURL: attachments.selectedURL, isSectionFocused: attachments.isFocused
).isEmpty)
// A window on its way out no folder, nothing to reveal is the row's own disabled clause.
attachments.cardFolder = nil
#expect(!reveal.isEnabled)
}
} }
// MARK: - F and the search field's two homes // MARK: - F and the search field's two homes