diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index fac8f62..fbf7897 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -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 diff --git a/Kanban/App/CardWindowHost.swift b/Kanban/App/CardWindowHost.swift index 40fa0d7..b0c3de5 100644 --- a/Kanban/App/CardWindowHost.swift +++ b/Kanban/App/CardWindowHost.swift @@ -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) ) diff --git a/Kanban/App/FutureCommands.swift b/Kanban/App/FutureCommands.swift index 668b02c..c03599a 100644 --- a/Kanban/App/FutureCommands.swift +++ b/Kanban/App/FutureCommands.swift @@ -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() } } diff --git a/Kanban/App/WindowAccessor.swift b/Kanban/App/WindowAccessor.swift index 0d69b5a..67fc2f2 100644 --- a/Kanban/App/WindowAccessor.swift +++ b/Kanban/App/WindowAccessor.swift @@ -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 diff --git a/Kanban/UI/Card/CardToolbar.swift b/Kanban/UI/Card/CardToolbar.swift index 79cae15..4a9aad1 100644 --- a/Kanban/UI/Card/CardToolbar.swift +++ b/Kanban/UI/Card/CardToolbar.swift @@ -6,6 +6,7 @@ extension NSToolbarItem.Identifier { static let cardEditBody = Self("card.editBody") static let cardRawSource = Self("card.rawSource") static let cardAddAttachment = Self("card.addAttachment") + static let cardShowSidebar = Self("card.showSidebar") } // MARK: - The card window's toolbar @@ -15,9 +16,12 @@ extension NSToolbarItem.Identifier { /// "**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 /// *is* the catalog here, and Customize offers rearrangement and removal rather than a choice of -/// items — which is exactly what a window with three functions should offer. +/// items — which is exactly what a window with three functions should offer. **Show Sidebar joined +/// them** (05-card-window.md ▸ Composition, the toggle added beside the toolbar-customization work): +/// a fourth default item, the trailing sidebar's own show/hide control, the `Show Trash` precedent +/// applied to the card window's one collapsible pane. /// -/// ### The three items are the three menu rows, predicates included +/// ### The four items are the four menu rows, predicates included /// /// - **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 @@ -31,6 +35,12 @@ extension NSToolbarItem.Identifier { /// `index.md`, so they're safe alongside a raw edit". Its predicate is likewise the row's own /// (`AddAttachmentCommand.isEnabled`), which is scope plus the read-only lock and says nothing /// about the body's mode. +/// - **Show Sidebar** is a toggle showing on-state too, mirroring the View ▸ Show Sidebar checkmark +/// (`ShowSidebarCommand`) — and it is the one item here with no window-scoped handle behind it: +/// its predicate reads `AppPreferences.showCardSidebar` straight off `UserDefaults`, the same +/// 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 +/// mutation, so the read-only lock has no say in it. /// /// Labels are the menu titles minus a trailing ellipsis, so File ▸ Add Attachment… labels as **Add /// Attachment** (03's own example). @@ -39,17 +49,28 @@ enum CardToolbar { static let identifier = "dev.rzen.indie.Kanban.card" - /// "The catalog is the same trio" — so the defaults are the catalog, in 03's order. + /// "The catalog is the same trio" plus Show Sidebar — so the defaults are the whole catalog, in + /// this file's order. static let defaultItems: [NSToolbarItem.Identifier] = [ .cardEditBody, .cardRawSource, .cardAddAttachment, + .cardShowSidebar, ] + /// - Parameters: + /// - isSidebarShown: Show Sidebar's read, defaulted to the real bit + /// (`AppPreferences.showCardSidebar`). Injectable so a test can drive the item without + /// touching the developer's own `UserDefaults.standard` domain — `StyleRecents`' own caution, + /// applied to the one item here with no window-scoped handle to hold a scratch value instead. + /// - setSidebarShown: Show Sidebar's write, defaulted to the real setter + /// (`AppPreferences.setShowCardSidebar`), for the same reason. static func specs( body: CardBodyPresentation, rawSource: CardRawSourceSession, - attachments: CardAttachments + attachments: CardAttachments, + isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar }, + setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) } ) -> [ToolbarItemSpec] { [ .mirroring( @@ -90,17 +111,38 @@ enum CardToolbar { perform: { [weak attachments] in attachments?.add() } ) ), + // **The one item here with no window-scoped handle** — its read and write are the two + // injected closures above, defaulted to `AppPreferences.showCardSidebar` / + // `.setShowCardSidebar`, the plain `UserDefaults`-backed bit every card window's sidebar + // (`CardWindowView`) and View ▸ Show Sidebar row (`ShowSidebarCommand`) already read and + // write the same way. `sidebar.right` is the trailing variant: the sidebar this toggles + // sits on the window's trailing edge (05-card-window.md ▸ Composition), never the leading + // one the plain `sidebar.left` glyph would imply. + .mirroring( + menuTitle: "Show Sidebar", + identifier: .cardShowSidebar, + symbol: "sidebar.right", + behavior: .toggle(isEnabled: { true }, isOn: isSidebarShown, setOn: setSidebarShown) + ), ] } static func controller( body: CardBodyPresentation, rawSource: CardRawSourceSession, - attachments: CardAttachments + attachments: CardAttachments, + isSidebarShown: @escaping () -> Bool = { AppPreferences.showCardSidebar }, + setSidebarShown: @escaping (Bool) -> Void = { AppPreferences.setShowCardSidebar($0) } ) -> WindowToolbarController { WindowToolbarController( identifier: identifier, - specs: specs(body: body, rawSource: rawSource, attachments: attachments), + specs: specs( + body: body, + rawSource: rawSource, + attachments: attachments, + isSidebarShown: isSidebarShown, + setSidebarShown: setSidebarShown + ), defaults: defaultItems ) } diff --git a/Kanban/UI/Card/CardWindowMetrics.swift b/Kanban/UI/Card/CardWindowMetrics.swift index 4414043..97a10b5 100644 --- a/Kanban/UI/Card/CardWindowMetrics.swift +++ b/Kanban/UI/Card/CardWindowMetrics.swift @@ -258,6 +258,11 @@ enum CardWindowMetrics { /// The window's minimum size: the sidebar's fixed width plus the body's floor, and tall enough /// for a title, its date line and a few lines of body. /// + /// - Parameter sidebar: whether the attributes sidebar is currently shown — View ▸ Show Sidebar + /// (05-card-window.md ▸ Composition, extended for the toggle). Hidden, it costs the window no + /// width at all, the comments column's own "the pane costs the window no width while it is not + /// mounted" rule applied to the sidebar's own visibility. Defaulted to `true` so every caller + /// that predates the toggle still asks the same question it always did. /// - Parameter commentsColumn: whether the comments pane is currently mounted **beside** the body /// — "the window's minimum width grows only while the column is shown side-by-side" /// (05-card-window.md ▸ Composition). Stacked, or hidden, the pane costs the window no width at @@ -267,10 +272,11 @@ enum CardWindowMetrics { /// /// Defaulted to `false` so every caller that predates the comments pane still asks the same /// question it always did. - static func minimumSize(bodyPointSize: CGFloat, commentsColumn: Bool = false) -> CGSize { + static func minimumSize(bodyPointSize: CGFloat, sidebar: Bool = true, commentsColumn: Bool = false) -> CGSize { + let sidebarComponent = sidebar ? sidebarWidth(bodyPointSize: bodyPointSize) : 0 let comments = commentsColumn ? commentsMinimumWidth(bodyPointSize: bodyPointSize) : 0 return CGSize( - width: sidebarWidth(bodyPointSize: bodyPointSize) + width: sidebarComponent + bodyMinimumWidth(bodyPointSize: bodyPointSize) + comments, height: (lineHeight(bodyPointSize: bodyPointSize) * 16).rounded() diff --git a/Kanban/UI/Card/CardWindowView.swift b/Kanban/UI/Card/CardWindowView.swift index afc421a..daa7dc7 100644 --- a/Kanban/UI/Card/CardWindowView.swift +++ b/Kanban/UI/Card/CardWindowView.swift @@ -17,11 +17,18 @@ import UniformTypeIdentifiers /// parameter and the body column takes none either. This view puts one of them in a frame; the /// arithmetic behind the frame is `CommentsMount`, which is pure and therefore checkable. /// -/// The sidebar is unchanged by any of it — it is a third pane, it has always been fixed-width, and -/// the resize flex still goes to the body and never to the sidebar. **The comments column is no -/// longer in that second category** (ruled 2026-08-09): its divider is user-draggable in the beside -/// mount, so the flex the body and the comments pane split is now the user's to move, within the two -/// floors `CardWindowMetrics.clampedCommentsColumnWidth` keeps either side from crossing. +/// The sidebar is unchanged by any of it — it is a third pane, it has always been fixed-width when +/// shown, and the resize flex still goes to the body and never to the sidebar. **The comments column +/// is no longer in that second category** (ruled 2026-08-09): its divider is user-draggable in the +/// beside mount, so the flex the body and the comments pane split is now the user's to move, within +/// the two floors `CardWindowMetrics.clampedCommentsColumnWidth` keeps either side from crossing. +/// +/// **Whether it shows at all is its own bit** (View ▸ Show Sidebar, extended for the toolbar-toggle +/// card): app-wide, persisted `showSidebar` below, mirrored by the card window's toolbar +/// (`CardToolbar`). Hidden, the pane and its divider are simply absent from the `HStack` and the body +/// column takes the width back through its own `.infinity` frame — no third state, no collapsed +/// sliver, the comments pane's own "costs the window no width while it is not mounted" rule applied +/// to the sidebar's visibility instead of the comments pane's mount. /// /// ### What this milestone builds, and what it deliberately does not /// @@ -117,6 +124,18 @@ struct CardWindowView: View { @AppStorage(AppPreferences.showCommentsKey) private var showComments = true @AppStorage(AppPreferences.commentsBesideBodyKey) private var commentsBesideBody = true + /// **View ▸ Show Sidebar** — the trailing attributes sidebar's own visibility bit, `showComments`'s + /// neighbour and shape exactly (05-card-window.md ▸ Composition, extended for the toolbar toggle): + /// app-wide, persisted, read here rather than passed in so this pane and the toolbar item that + /// mirrors it (`CardToolbar`) and the View-menu row that mirrors it too (`ShowSidebarCommand`) are + /// provably the same bit. + @AppStorage(AppPreferences.showCardSidebarKey) private var showSidebar = true + + /// Reduce Motion, read from the environment because this is a view — `Motion`'s own split between + /// callers that have one and callers (a menu command, a toolbar item) that reach `Motion + /// .prefersReducedMotion` instead. + @Environment(\.accessibilityReduceMotion) private var reduceMotion + /// **The comments column's user-set width**, in points — the divider's memory (05-card-window.md /// ▸ The comments column, extended 2026-08-09). `@AppStorage` for the same reason its two /// neighbours above are: every open card window's divider answers to one figure. `0` is the @@ -203,14 +222,25 @@ struct CardWindowView: View { contentPanes .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - Divider() + // **View ▸ Show Sidebar** (`ShowSidebarCommand`, `CardToolbar`): the whole trailing + // column, divider included, comes and goes with the one bit — the body/comments + // columns above take the width back through their own `.infinity` frames, exactly as + // they do while the pane is merely narrow. A structural reflow, the Show Trash + // re-divide's own voice (`Motion.structural`), with the pane itself sliding off the + // trailing edge it lives on (`Motion.cardSidebarTransition`) rather than just + // vanishing. + if showSidebar { + Divider() - sidebar - // Fixed, and the one place it comes from. - .frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize)) - .frame(maxHeight: .infinity, alignment: .top) - .background(.background.secondary) + sidebar + // Fixed, and the one place it comes from. + .frame(width: CardWindowMetrics.sidebarWidth(bodyPointSize: bodyPointSize)) + .frame(maxHeight: .infinity, alignment: .top) + .background(.background.secondary) + .transition(Motion.cardSidebarTransition(reduced: reduceMotion)) + } } + .animation(Motion.structural(reduced: reduceMotion), value: showSidebar) } } @@ -397,6 +427,44 @@ struct CardWindowView: View { } } +// MARK: - View ▸ Show Sidebar + +/// View ▸ Show Sidebar (checkmark toggle, **no default chord**) — the trailing attributes sidebar's +/// visibility (05-card-window.md ▸ Composition, extended for the toolbar toggle). +/// +/// `ShowCommentsCommand`'s shape and reasons exactly: one bit, app-wide and persisted, the checkmark +/// reading exactly that bit so the row never lies, and it mirrors the toolbar's own Show Sidebar item +/// (`CardToolbar`) the way View ▸ Show Trash mirrors its toolbar twin — a menu command with a second +/// face, never two implementations of one. +/// +/// Validation is **scope and nothing else**, `ShowCommentsCommand`'s own posture: with no card window +/// in front there is no focused value and the row disables, and the read-only lock plays no part — +/// showing or hiding a pane is not a mutation. `\.cardAttachments` is read only as the "a card window +/// is frontmost" signal `AddAttachmentCommand` already uses; the sidebar this toggles shows attachments +/// among its other sections, but the row does not otherwise touch that handle. +struct ShowSidebarCommand: View { + + @FocusedValue(\.cardAttachments) private var attachments + @AppStorage(AppPreferences.showCardSidebarKey) private var isShown = true + + static func isEnabled(_ attachments: CardAttachments?) -> Bool { + attachments != nil + } + + /// The toggle's binding, `ShowTrashCommand.isVisible`'s own shape: the setter routes through + /// `AppPreferences.setShowCardSidebar` — the one write path this bit's two faces share — rather + /// than writing `$isShown` directly, which is what makes the animated reflow the toolbar item + /// gets true of this row too. + private var isVisible: Binding { + Binding(get: { isShown }, set: { AppPreferences.setShowCardSidebar($0) }) + } + + var body: some View { + Toggle("Show Sidebar", isOn: isVisible) + .disabled(!Self.isEnabled(attachments)) + } +} + // MARK: - The window-wide drop /// Attaches the whole-window file drop, or nothing at all. diff --git a/Kanban/UI/Motion.swift b/Kanban/UI/Motion.swift index c125724..68b4a93 100644 --- a/Kanban/UI/Motion.swift +++ b/Kanban/UI/Motion.swift @@ -182,6 +182,19 @@ enum Motion { transientSearchAppearance(reduced: reduced).transition } + /// The card window's attributes sidebar, arriving or leaving — View ▸ Show Sidebar's one visible + /// consequence (05-card-window.md ▸ Composition ▸ The attributes sidebar). Slides from the + /// trailing edge, where the sidebar itself sits — `transientSearchAppearance`'s own reasoning + /// (the surface arrives from the edge it lives on) applied to this one's edge instead of the + /// toolbar's. + static func cardSidebarAppearance(reduced: Bool) -> Appearance { + reduced ? .crossfade : .slideAndFade(from: .trailing) + } + + static func cardSidebarTransition(reduced: Bool) -> AnyTransition { + cardSidebarAppearance(reduced: reduced).transition + } + // MARK: - The AppKit face /// The lane-resize window animation's duration, for `NSAnimationContext` — which takes a number diff --git a/KanbanTests/CardWindowShellTests.swift b/KanbanTests/CardWindowShellTests.swift index ca96a37..7667b45 100644 --- a/KanbanTests/CardWindowShellTests.swift +++ b/KanbanTests/CardWindowShellTests.swift @@ -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 diff --git a/KanbanTests/MotionTests.swift b/KanbanTests/MotionTests.swift index 94e1c85..bfd7c59 100644 --- a/KanbanTests/MotionTests.swift +++ b/KanbanTests/MotionTests.swift @@ -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 diff --git a/KanbanTests/ToolbarTests.swift b/KanbanTests/ToolbarTests.swift index 9e4b12b..71040aa 100644 --- a/KanbanTests/ToolbarTests.swift +++ b/KanbanTests/ToolbarTests.swift @@ -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