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
+27
View File
@@ -178,6 +178,33 @@ struct CardWindowMetricsTests {
#expect(CardWindowMetrics.minimumSize(bodyPointSize: size).height > 0)
}
@Test("Hiding the sidebar shrinks the window's minimum by exactly its width")
func hidingTheSidebarShrinksTheMinimum() {
// View Show Sidebar's own mirror of the comments column's own rule
// (`CommentsWindowMinimumTests.theMinimumGrowsOnlyBeside`): a pane costs the window no width
// while it is not shown.
let size: CGFloat = 13
let shown = CardWindowMetrics.minimumSize(bodyPointSize: size)
let hidden = CardWindowMetrics.minimumSize(bodyPointSize: size, sidebar: false)
#expect(shown.width == hidden.width + CardWindowMetrics.sidebarWidth(bodyPointSize: size))
#expect(hidden.width == CardWindowMetrics.bodyMinimumWidth(bodyPointSize: size))
#expect(hidden.height == shown.height, "hiding the sidebar costs width, never height")
}
@Test("Hiding the sidebar composes with the comments column's own minimum")
func hidingTheSidebarComposesWithTheCommentsColumn() {
let size: CGFloat = 13
let minimum = CardWindowMetrics.minimumSize(bodyPointSize: size, sidebar: false, commentsColumn: true)
#expect(
minimum.width
== CardWindowMetrics.bodyMinimumWidth(bodyPointSize: size)
+ CardWindowMetrics.commentsMinimumWidth(bodyPointSize: size),
"sidebar hidden and comments beside: the minimum is the body's floor plus the comments floor, nothing else"
)
}
@Test("A first card window opens larger than the minimum")
func theDefaultSizeIsRoomier() {
let size: CGFloat = 13
+8
View File
@@ -145,6 +145,14 @@ struct ReduceMotionVariantTests {
#expect(Motion.cardAppearance(reduced: true) == .crossfade)
#expect(Motion.laneAppearance(reduced: true) == .crossfade)
}
/// The card window's sidebar slides from the trailing edge it lives on View Show Sidebar's
/// one visible consequence and, like every slide-and-fade here, crossfades instead under
/// Reduce Motion.
@Test func theSidebarSlidesFromTheTrailingEdgeItLivesOn() {
#expect(Motion.cardSidebarAppearance(reduced: false) == .slideAndFade(from: .trailing))
#expect(Motion.cardSidebarAppearance(reduced: true) == .crossfade)
}
}
// MARK: - The create handoff
+43 -4
View File
@@ -598,16 +598,16 @@ struct CardToolbarTests {
return (body, raw, attachments)
}
@Test("The default set is the whole catalog — the trio, in 03's order")
@Test("The default set is the whole catalog — the trio plus Show Sidebar, in this file's order")
func defaultsAreTheCatalog() {
let (body, raw, attachments) = makeHandles()
let specs = CardToolbar.specs(body: body, rawSource: raw, attachments: attachments)
// "Card window default: Edit Body · Raw Source · Add Attachment the catalog is the same
// trio."
#expect(CardToolbar.defaultItems == [.cardEditBody, .cardRawSource, .cardAddAttachment])
// trio" joined by Show Sidebar, the fourth default item the toolbar-toggle card added.
#expect(CardToolbar.defaultItems == [.cardEditBody, .cardRawSource, .cardAddAttachment, .cardShowSidebar])
#expect(specs.map(\.identifier) == CardToolbar.defaultItems)
#expect(specs.map(\.label) == ["Edit Body", "Raw Source", "Add Attachment"])
#expect(specs.map(\.label) == ["Edit Body", "Raw Source", "Add Attachment", "Show Sidebar"])
}
@Test("Every item's symbol resolves on this system")
@@ -711,6 +711,45 @@ struct CardToolbarTests {
#expect(!addAttachment.isEnabled)
#expect(addAttachment.isEnabled == AddAttachmentCommand.isEnabled(attachments))
}
/// **Show Sidebar is always enabled and drives its own injected write path** the two closures
/// `CardToolbar.specs` defaults to `AppPreferences.showCardSidebar` / `.setShowCardSidebar`, held
/// here over a local `Bool` instead so this test never touches the developer's own
/// `UserDefaults.standard` domain (`CardSessionUndoTests`' quick-style caution, applied to the one
/// 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")
func showSidebarTogglesItsOwnBit() {
let (body, raw, attachments) = makeHandles()
var shown = true
let specs = CardToolbar.specs(
body: body,
rawSource: raw,
attachments: attachments,
isSidebarShown: { shown },
setSidebarShown: { shown = $0 }
)
guard let showSidebar = specs.spec(.cardShowSidebar) else {
Issue.record("no Show Sidebar item")
return
}
#expect(showSidebar.isOn == true)
#expect(showSidebar.isEnabled, "showing or hiding a pane is not a mutation the read-only lock gates")
showSidebar.activate()
#expect(shown == false, "the item drives the same bit the injected closures read")
#expect(showSidebar.isOn == false)
showSidebar.activate()
#expect(shown == true)
#expect(showSidebar.isOn == true)
// Raw source and the read-only lock are both the *other* items' predicates Show Sidebar
// reads neither.
raw.enter()
attachments.isEditable = false
#expect(showSidebar.isEnabled)
}
}
// MARK: - F and the search field's two homes