A card window toolbar control shows and hides the trailing sidebar — View ▸ Show Sidebar joins Edit Body, Raw Source and Add Attachment

The card window's trailing attributes sidebar has always been unconditional
since m6; this adds View ▸ Show Sidebar (a checkmark toggle, ShowComments'
shape) and a matching toolbar item on the customizable card toolbar
(NSToolbar/WindowToolbarController), a fourth default beside Edit Body,
Raw Source and Add Attachment. sidebar.right for the trailing pane; one
shared animated write path (AppPreferences.setShowCardSidebar) both faces
call, structural-voice reflow with a trailing slide-and-fade transition
(Motion.cardSidebarTransition), Reduce Motion respected throughout.

CardWindowMetrics.minimumSize gains a sidebar: Bool = true parameter so a
hidden sidebar shrinks the window's floor, composing with the existing
commentsColumn parameter. The toolbar item's read/write are injectable
closures (defaulted to the real UserDefaults-backed pair) so its plumbing
is testable without touching the developer's own preferences domain.
WindowToolbarController's observation tracking only sees @Observable
reads, so a small HostedWindowController.revalidateToolbar() plus an
onChange nudge keeps the toolbar button's on-state in step with the
View-menu row's write.

Scope held narrowly to visibility, per the card: no sidebar section
reordering, no action-moving.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
2026-08-09 09:14:51 -04:00
parent 55663e855a
commit d905e73960
11 changed files with 301 additions and 28 deletions
+35
View File
@@ -127,6 +127,41 @@ public enum AppPreferences {
/// `double(forKey:)` reading safe here in a way `boardZoomLevel`'s note warns it usually is not.
public static let commentsColumnWidthKey = "commentsColumnWidth"
// MARK: The attributes sidebar's visibility
/// **View Show Sidebar** the card window's trailing attributes sidebar (05-card-window.md
/// Composition), `showComments`'s shape and reasons exactly: one bit, app-wide, persisted across
/// restarts, and the checkmark (and the toolbar toggle that mirrors it, `CardToolbar`) reads
/// exactly this value so neither ever lies.
///
/// **Default on.** The sidebar has been part of every card window since m6; an unset key must
/// read as the window everyone already knows rather than as a surprise collapse the first time
/// this preference exists to read.
public static let showCardSidebarKey = "showCardSidebar"
/// Read outside a view `CardToolbar`'s toggle item, which is AppKit rather than SwiftUI and so
/// has no `@AppStorage` of its own (`lastCardWindowSize`'s reason).
public static var showCardSidebar: Bool {
UserDefaults.standard.object(forKey: showCardSidebarKey) as? Bool ?? true
}
/// Show/Hide Sidebar's whole behaviour **the one write path both of its faces share**
/// (`BoardStore.setTrashVisible`'s precedent: "a toolbar item is a menu command with a different
/// face, never a second implementation of it"), so `ShowSidebarCommand`'s Toggle and
/// `CardToolbar`'s item call this rather than writing `UserDefaults` each in their own words.
///
/// **A user-initiated structural change**, so it animates in the structural voice and goes
/// instant under Reduce Motion the Show Trash re-divide's own reasoning, applied to a pane
/// leaving a window instead of a lane leaving a strip. Reduce Motion is read from AppKit rather
/// than from `@Environment` for the same reason `setTrashVisible` reads it that way: a toolbar
/// item's action and a menu row's both run outside any rendered hierarchy.
@MainActor
public static func setShowCardSidebar(_ shown: Bool) {
withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
UserDefaults.standard.set(shown, forKey: showCardSidebarKey)
}
}
/// The quick-style row's recently-used backgrounds an array of palette names / hex strings,
/// most-recent-first (03-board-ui.md § Styling Controls: "Recents are app-wide and persist
/// app-side (user preference, never board data)"; 11-command-nexus.md files it under the
+22 -5
View File
@@ -302,6 +302,12 @@ struct CardWindowHost: View {
/// threading the pair through a view that would then have to publish them back up.
@AppStorage(AppPreferences.showCommentsKey) private var showComments = true
@AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true
/// The sidebar's own visibility bit, read here for `showComments`' one reason: the window's
/// **minimum size** depends on it too (`minimumSize`). The toolbar item reads
/// `AppPreferences.showCardSidebar` directly (`CardToolbar`, an AppKit seam with no `@AppStorage`
/// of its own), and `CardWindowView` reads this same key again itself three readers of one
/// `UserDefaults` key, `showComments`' own arrangement.
@AppStorage(AppPreferences.showCardSidebarKey) private var showSidebar = true
private enum Phase {
case opening
@@ -426,15 +432,23 @@ struct CardWindowHost: View {
isClosePending = false
windowController.closeAfterFlush()
}
// **The toolbar's Show Sidebar item, nudged fresh** `WindowToolbarController`'s own
// observation tracking only sees `@Observable` reads (its header explains why: menu
// commands and AppKit have no `@Environment` to poll), and `AppPreferences.showCardSidebar`
// is a plain `UserDefaults` read, not one. The toolbar's own click already revalidates
// itself (`toggleFired`); this is the same nudge for the View-menu row's write, which is
// the only other place this bit changes.
.onChange(of: showSidebar) { _, _ in windowController.revalidateToolbar() }
.onDisappear { finish() }
}
/// **The minimum grows only while the comments pane is beside the body** (05-card-window.md
/// Composition) which is the whole reason the stacked mount exists, so a narrow display keeps
/// the minimum it always had.
/// **The minimum grows only while the comments pane is beside the body, and shrinks while the
/// sidebar is hidden** (05-card-window.md Composition) the stacked mount's own reason, and
/// View Show Sidebar's mirror of it: a window with neither costs the width of neither.
private var minimumSize: CGSize {
CardWindowMetrics.minimumSize(
bodyPointSize: CardWindowMetrics.bodyPointSize,
sidebar: showSidebar,
commentsColumn: showComments && commentsBesideBody
)
}
@@ -915,8 +929,11 @@ struct CardWindowHost: View {
// 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). It carries the three window-scoped handles above rather than a store, which is
// why it is installed here and not at attach: those are this window's, and so is it.
// Window), joined by Show Sidebar (below). It carries the three window-scoped handles above
// rather 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
// window answers to, so this call leaves the two injectable parameters unnamed.
windowController.installToolbar(
CardToolbar.controller(body: bodyPresentation, rawSource: rawSource, attachments: attachments)
)
+8
View File
@@ -107,6 +107,13 @@ struct FindSteppingCommands: View {
/// The comments pane's two rows join them (11-command-nexus.md lists Show Comments and Comments
/// Beside Body between Edit Body and Raw Source): both are live, both are app-wide persisted bits,
/// and both are scoped to the card window (`ShowCommentsCommand`, `CommentsBesideBodyCommand`).
///
/// **Show Sidebar joined at the end** (05-card-window.md Composition, the toolbar-toggle card):
/// the trailing attributes sidebar's own visibility bit, `ShowCommentsCommand`'s shape exactly and
/// mirroring the toolbar's own item (`CardToolbar`) the way View Show Trash mirrors its toolbar
/// twin. Appended rather than interleaved with the comments pair, to leave the block 11 already
/// documents (Edit Body · Show Comments · Comments Beside Body · Raw Source) in the order it names
/// this row is additive, not a resequencing.
struct CardViewCommands: View {
var body: some View {
EditBodyCommand()
@@ -114,5 +121,6 @@ struct CardViewCommands: View {
CommentsBesideBodyCommand()
RawSourceCommand()
FutureCommand(title: "History")
ShowSidebarCommand()
}
}
+10
View File
@@ -230,6 +230,16 @@ final class HostedWindowController: NSObject, NSWindowDelegate {
window.toolbar = nil
}
/// Re-validates this window's toolbar items against their current predicates, on demand.
///
/// `WindowToolbarController`'s own observation tracking (`trackValidationState`) only notices
/// `@Observable` reads changing; a predicate that reads a plain `UserDefaults`-backed bit instead
/// (`AppPreferences.showCardSidebar`, `CardToolbar`) is invisible to it. A no-op before a toolbar
/// exists, which covers every window kind that has none.
func revalidateToolbar() {
toolbarController?.revalidate()
}
// MARK: Title visibility
/// Hides this window's title from the title bar, leaving the toolbar exactly as it renders today