Full relative text scaling per DESIGN/10: BoardMetrics is the board strip's geometry as a pure function of the body point size (CardWindowMetrics' twin) — lane plate/header/band, card corner/stripe/padding, masonry spacing, the drop model's nominal card height, resize-handle geometry, trash hatch pitch, and both window floors all derive from an em; CardFaceMetrics folded in. The two fixed font sizes (welcome brand/glyph) went relative; the toolbar search field is 17 ems like the transient bar's. The no-horizontal-scroll invariant is pinned by test at six text sizes by twelve lane counts. Accommodations is Motion's sibling for the visual settings: Increase Contrast adds a flat point to strokes (monotone, hierarchy-preserving), gives borderless card/lane plates a resting separator hairline, and takes faded accents to full alpha; Reduce Transparency turns the transient search bar's glass solid and does the same for the alpha washes that composite over a user-chosen board background (trash plate, hatched header, drag shadow). Reduce Motion audited — every animated surface already routes through Motion with a reduced variant; no gaps. Full Keyboard Access: the template chooser's tiles were pointer-only — now focusable, arrow-navigable (clamped, StyleWellGrid's rule), Space picks, Return stays the sheet's default action, focus names the selection one-way. The board's single tab stop shows its focus ring under FKA (focusEffectDisabled inverts). Style editor verified already conformant. Edge accents verified text-free; trash hatch pitch now font-derived so it still reads as hatching at large text. 1549 unit tests green, both schemes build. Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
137 lines
6.5 KiB
Swift
137 lines
6.5 KiB
Swift
import AppKit
|
|
|
|
// MARK: - Identifiers
|
|
|
|
extension NSToolbarItem.Identifier {
|
|
static let boardSearch = Self("board.search")
|
|
static let boardNewCard = Self("board.newCard")
|
|
static let boardNewLane = Self("board.newLane")
|
|
static let boardUndo = Self("board.undo")
|
|
static let boardRedo = Self("board.redo")
|
|
static let boardShowTrash = Self("board.showTrash")
|
|
}
|
|
|
|
// MARK: - The board window's toolbar
|
|
|
|
/// The board window's toolbar (03-board-ui.md ▸ Toolbar).
|
|
///
|
|
/// ### The default is one item, and the catalog is five more
|
|
///
|
|
/// "**Board window default: the search field, nothing else** — trailing, the one default item; the
|
|
/// titlebar stays clean." The flexible space ahead of it is what "trailing" means to `NSToolbar`.
|
|
///
|
|
/// "**Catalog** (available via Customize): New Card, New Lane, Undo, Redo …, Show Trash (toggle
|
|
/// state matching the View menu checkmark)." Every one of them is the *same command* as its menu row
|
|
/// — the predicates below are the rows' own (`BoardStore.newCardTarget`, `acceptsBoardMutations`),
|
|
/// and the two actions with consequences call the rows' own functions (`beginNewCard`,
|
|
/// `setTrashVisible`) rather than restating them. That is what makes "toolbars are pure enhancement"
|
|
/// true of the code: removing every item removes nothing but a shortcut to a menu row.
|
|
///
|
|
/// "The board popover deliberately has **no toolbar item** — the window-title widget is its
|
|
/// committed home" — so there is no Board Info entry here, and its absence is pinned by a test.
|
|
///
|
|
/// ### Undo and Redo are the responder chain's, exactly as the menu's are
|
|
///
|
|
/// The app ships no Undo/Redo rows of its own: those are the standard Edit-menu items, nil-target
|
|
/// `undo:`/`redo:` resolved up the responder chain (`KanbanApp.menuCommands`). The toolbar items
|
|
/// carry the same actions with the same nil target, so "matching their menu items" (03) is not a
|
|
/// predicate written here — it is literally the same validation. Both reach the board window, whose
|
|
/// `windowWillReturnUndoManager` hands back the session's `BoardUndoManager`, and both therefore
|
|
/// enable exactly when that board has a step to cross and no read-only lock stands
|
|
/// (13-native-undo.md ▸ Rules). **Every base board has undo**, so there is no edition-shaped
|
|
/// disablement to write: 03's parenthetical about boards without undo is 06's *git* substrate, which
|
|
/// only Pro binds.
|
|
///
|
|
/// Their labels are the design's one exception to the menu-title rule: `NSUndoManager` rewrites the
|
|
/// *menu* titles as the stack changes ("Undo Move Card"), which a toolbar label does not track, so
|
|
/// these two are built from static labels (`ToolbarItemSpec.staticLabel`).
|
|
@MainActor
|
|
enum BoardToolbar {
|
|
|
|
/// Shared by every board window, which is what makes the user's arrangement the *app's* rather
|
|
/// than one window's — Finder's behaviour, and the reason the identifier is a constant.
|
|
static let identifier = "dev.rzen.indie.Kanban.board"
|
|
|
|
/// "The search field, nothing else — trailing, the one default item."
|
|
static let defaultItems: [NSToolbarItem.Identifier] = [.flexibleSpace, .boardSearch]
|
|
|
|
static func specs(store: BoardStore, search: BoardSearchPresentation) -> [ToolbarItemSpec] {
|
|
[
|
|
.mirroring(
|
|
menuTitle: "New Card",
|
|
identifier: .boardNewCard,
|
|
symbol: "doc.badge.plus",
|
|
behavior: .button(
|
|
isEnabled: { [weak store] in store?.newCardTarget != nil },
|
|
perform: { [weak store] in store?.beginNewCard() }
|
|
)
|
|
),
|
|
.mirroring(
|
|
menuTitle: "New Lane",
|
|
identifier: .boardNewLane,
|
|
symbol: "rectangle.stack.badge.plus",
|
|
behavior: .button(
|
|
isEnabled: { [weak store] in store?.acceptsBoardMutations == true },
|
|
perform: { [weak store] in store?.createLane() }
|
|
)
|
|
),
|
|
.staticLabel(
|
|
"Undo",
|
|
identifier: .boardUndo,
|
|
symbol: "arrow.uturn.backward",
|
|
behavior: .responderAction(NSSelectorFromString("undo:"))
|
|
),
|
|
.staticLabel(
|
|
"Redo",
|
|
identifier: .boardRedo,
|
|
symbol: "arrow.uturn.forward",
|
|
behavior: .responderAction(NSSelectorFromString("redo:"))
|
|
),
|
|
.mirroring(
|
|
menuTitle: "Show Trash",
|
|
identifier: .boardShowTrash,
|
|
symbol: "trash",
|
|
behavior: .toggle(
|
|
isEnabled: { [weak store] in store != nil },
|
|
isOn: { [weak store] in store?.transient.isTrashVisible == true },
|
|
setOn: { [weak store] shown in store?.setTrashVisible(shown) }
|
|
)
|
|
),
|
|
.staticLabel(
|
|
"Search",
|
|
identifier: .boardSearch,
|
|
symbol: nil,
|
|
// The field's width in *characters* rather than points (10-accessibility.md ▸ Text
|
|
// scaling: "no fixed point sizes") — the same 17 ems the transient bar's field takes
|
|
// (`BoardSearchBar`), so ⌘F's two homes are one width whichever the user is in.
|
|
behavior: .control(width: BoardMetrics.em(17, bodyPointSize: BoardMetrics.bodyPointSize)) {
|
|
[weak store] willBeInserted in
|
|
guard willBeInserted, let store else {
|
|
return BoardSearchFieldController.makePaletteField()
|
|
}
|
|
return BoardSearchFieldController.makeField(
|
|
store: store,
|
|
presentation: search,
|
|
home: .toolbar
|
|
)
|
|
}
|
|
),
|
|
]
|
|
}
|
|
|
|
/// The window's toolbar, wired to tell the search presentation where its field currently lives —
|
|
/// which is the whole input to ⌘F's transient fallback (03: "with the field removed from the
|
|
/// toolbar, invoking it surfaces the field transiently until the search clears").
|
|
static func controller(store: BoardStore, search: BoardSearchPresentation) -> WindowToolbarController {
|
|
let controller = WindowToolbarController(
|
|
identifier: identifier,
|
|
specs: specs(store: store, search: search),
|
|
defaults: defaultItems
|
|
)
|
|
controller.onInstalledItemsChanged = { [weak search] identifiers in
|
|
search?.isInstalledInToolbar = identifiers.contains(.boardSearch)
|
|
}
|
|
return controller
|
|
}
|
|
}
|