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), 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** — 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) } }