From cfee4a4b41175a2f9a1beeab2cb43dda5077f776 Mon Sep 17 00:00:00 2001 From: rzen Date: Fri, 7 Aug 2026 11:22:02 -0400 Subject: [PATCH] =?UTF-8?q?The=20board=20learns=20to=20zoom=20=E2=80=94=20?= =?UTF-8?q?eight=20rungs=20on=20one=20ruler,=20and=20Actual=20Size=20is=20?= =?UTF-8?q?the=20untouched=20board?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0): 75%–200% in eight rungs, app-wide and persisted (the Show Comments precedent) — a viewing comfort, not a property of any one board. The level travels as BoardZoomContext in the environment, injected on BoardView alone so the banner strip, search bar, sheets and popovers stay at the system size; the environment is also what carries it through CardFaceView's equality gate, which compares nothing that moves with the level. Every BoardMetrics figure follows zoom.bodyPointSize — card and lane chrome, drag replicas and the count badge, the resize handle, the trash column — and the drop registry carries the ruler for event-time reads, with the autoscroller's three reaches turning font-derived (reachSide named as the stripGap it always equalled). Lanes still divide the window; zoom never moves the window or its floor. The toolbar gains a catalog-only Zoom In/Out pair mirroring the menu rows' predicate; zoom holds shut mid-drag (frozen geometry), each rung announces itself to VoiceOver, and the render suite pins both invariants: a rung repaints every face, a no-op Actual Size repaints nothing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy --- CHANGELOG.md | 2 + DESIGN/11-command-nexus.md | 3 + Kanban/App/AppModel.swift | 20 + Kanban/App/BoardWindowHost.swift | 17 +- Kanban/KanbanApp.swift | 14 +- Kanban/LiveStore/BoardZoomStore.swift | 103 +++++ Kanban/UI/AccessibilityPhrases.swift | 12 + Kanban/UI/Board/BoardDrops.swift | 26 +- Kanban/UI/Board/BoardToolbar.swift | 59 ++- Kanban/UI/Board/BoardView.swift | 27 +- Kanban/UI/Board/CardFaceView.swift | 22 +- Kanban/UI/Board/DragAutoScrollMath.swift | 43 +- Kanban/UI/Board/DragAutoScroller.swift | 18 +- Kanban/UI/Board/DragShadow.swift | 24 +- Kanban/UI/Board/LaneResizeHandle.swift | 8 +- Kanban/UI/Board/LaneView.swift | 35 +- Kanban/UI/Board/TrashLaneRowView.swift | 16 +- Kanban/UI/Board/TrashLaneView.swift | 12 +- Kanban/UI/Board/ZoomCommands.swift | 110 +++++ Kanban/UI/BoardZoom.swift | 247 ++++++++++ KanbanTests/BoardRenderPerformanceTests.swift | 101 ++++- KanbanTests/BoardZoomTests.swift | 428 ++++++++++++++++++ KanbanTests/DragAutoScrollMathTests.swift | 55 ++- KanbanTests/DropSlotMathTests.swift | 2 +- KanbanTests/HistoryProviderTests.swift | 23 +- KanbanTests/RestingLayoutCacheTests.swift | 8 +- KanbanTests/ToolbarTests.swift | 138 +++++- README.md | 4 +- RENDER-INSTRUMENTATION.md | 15 + 29 files changed, 1491 insertions(+), 101 deletions(-) create mode 100644 Kanban/LiveStore/BoardZoomStore.swift create mode 100644 Kanban/UI/Board/ZoomCommands.swift create mode 100644 Kanban/UI/BoardZoom.swift create mode 100644 KanbanTests/BoardZoomTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 1deedf5..74dabbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,3 +79,5 @@ On git boards, everything done in a card window becomes a single history entry w Switch or create branches from the board popover, with an explicit save-or-discard step protecting unsaved card edits. The name and email on a board's history entries are editable in the board popover and stored in the board's own repository. + +Zoom the board in and out from the View menu (⌘+ and ⌘−, ⌘0 for actual size), and the size you settle on is remembered across launches. diff --git a/DESIGN/11-command-nexus.md b/DESIGN/11-command-nexus.md index 551ff96..58582e5 100644 --- a/DESIGN/11-command-nexus.md +++ b/DESIGN/11-command-nexus.md @@ -47,6 +47,9 @@ The single source of truth for **every command and action the app can perform** | Board | Pull / Push | — (no default) | Remote-backed boards only (07); disabled during 06's abnormal-state pause (the whole git surface holds) and on an unresolvable remote (07's one-time remote picker case); popover twins exist | | Board | Board Settings… | — (no default) | Opens the board settings sheet (03 — the 2026-07-31 popover/sheet split); popover row twins it | | View | Show Trash (checkmark toggle) | — (no default) | Board window — ⇧⌘T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled (settled; see Standard macOS furniture), so the chord is the system's; assign one via the remapping mechanism if wanted (04 ▸ Configurable bindings) | +| View | Zoom In | ⌘+ | Board window; steps the board's zoom one rung up the ladder (03 ▸ Layout — zoom). Disabled at the top rung, and while a drag session is in flight (a drag freezes geometry the level feeds — the dragged run's heights and the resting-layout cache) | +| View | Zoom Out | ⌘− | Board window; the twin, one rung down. Disabled at the bottom rung and under the same guard | +| View | Actual Size | ⌘0 | Board window; returns to 100%, where the strip renders exactly what it rendered before zoom existed. Disabled when already there, and under the same guard. The level is app-wide and persisted across restarts (the Show Comments precedent) — a zoom is a viewing comfort, not a property of any one board | | View | Edit Body (checkmark toggle) | ⌘E | Card window; disabled while Raw Source is active | | View | Show Comments (checkmark toggle) | — (no default) | Card window; app-wide, persisted across restarts (re-ruled 2026-07-29 — no content-derived auto-show; 05 ▸ The comments column) | | View | Comments Beside Body (checkmark toggle) | — (no default) | Card window; checked = side-by-side (default), unchecked = body over comments; app-wide, persisted (05 ▸ Composition) | diff --git a/Kanban/App/AppModel.swift b/Kanban/App/AppModel.swift index 01d3524..854a304 100644 --- a/Kanban/App/AppModel.swift +++ b/Kanban/App/AppModel.swift @@ -114,6 +114,19 @@ public enum AppPreferences { /// the key is declared here with its neighbours for `WindowID`'s reason. public static let quickStyleBackgroundsKey = "quickStyleBackgrounds" + /// **The board's zoom level** — "app-wide and persisted across restarts" (11-command-nexus.md + /// ▸ View ▸ Actual Size; 03-board-ui.md ▸ Layout — zoom). A rung on `BoardZoom.levels`, stored as + /// the multiplier itself. Read and written by `BoardZoomStore`, which owns the ladder's rules; the + /// key is declared here with its neighbours for `WindowID`'s reason. + /// + /// **Every read goes through `BoardZoom.normalize`**, and this one cannot use the + /// `object(forKey:) as? Double ?? default` idiom its neighbours use to tell "off" from "never set" + /// — the trap here is worse than an ambiguous default. `double(forKey:)` answers 0 for an unset + /// key, and 0 is not merely a wrong level: it drives every `BoardMetrics.em` multiple to its 1pt + /// floor and draws a board of hairlines. Normalising is what makes an unset, hand-edited or + /// stale-from-a-future-build value indistinguishable from a legal one downstream. + public static let boardZoomLevelKey = "boardZoomLevel" + /// The cached subscription facts behind the tier decision (12-editions.md ▸ The entitlement) — /// JSON-encoded `SubscriptionFacts`, read and written by `ProEntitlement`. /// @@ -252,6 +265,12 @@ public final class AppModel { /// board-scoped and this list deliberately is not. public let styleRecents: StyleRecents + /// The board's app-wide zoom level (03-board-ui.md ▸ Layout — zoom). Owned here for + /// `styleRecents`' reason exactly: app-scoped, persisted beside it, and reached by every board + /// window through the environment — while the menu rows and the toolbar buttons, which live + /// outside every scene's environment, reach it through this object. + public let zoom: BoardZoomStore + /// The app's one drag session (DRAG-REORDER.md; 04-interactions.md ▸ Drag and drop). /// /// App-wide for the reason cross-board drags exist at all: **a drag crosses windows**, so the @@ -668,6 +687,7 @@ public final class AppModel { ) { boardRegistry = BoardRegistry(storageURL: registryStorageURL) styleRecents = StyleRecents(defaults: preferences) + zoom = BoardZoomStore(defaults: preferences) clipboard = ClipboardStore(stagingRoot: clipboardStagingRoot) // Reads the cached facts and nothing else — no StoreKit API is touched until // `ProEntitlement.start()`, which the app's launch calls and a test host never does. diff --git a/Kanban/App/BoardWindowHost.swift b/Kanban/App/BoardWindowHost.swift index 1332e9f..c84d9cb 100644 --- a/Kanban/App/BoardWindowHost.swift +++ b/Kanban/App/BoardWindowHost.swift @@ -138,6 +138,11 @@ struct BoardWindowHost: View { // 10-accessibility.md's full-relative-scaling rule): at a large system text size a // 640×400 floor would be narrower than two lane headers, and "every lane is always on // screen" would degrade into a strip of truncation. + // + // **The *system* size, not the zoomed one** (03-board-ui.md ▸ Layout — zoom: "Zoom never + // moves the window"). Zooming to 200% must not push a floor up under a window the user + // already sized: moving the window belongs to the right-edge lane drag alone, and a + // minimum that grew with the level would resize every open board from a menu item. .frame( minWidth: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).width, minHeight: BoardMetrics.windowMinimumSize(bodyPointSize: BoardMetrics.bodyPointSize).height @@ -193,6 +198,11 @@ struct BoardWindowHost: View { openCard: openCard, search: boardSearch ) + // **The zoom level enters here and nowhere else** (03-board-ui.md ▸ Layout — zoom). + // On `BoardView` rather than on the `VStack`, deliberately: the level is the *board's* + // ruler, so the banner strip and the transient search bar above it — chrome, not the + // board — stay at the system's size, as do the sheets and popovers this window hosts. + .environment(\.boardZoom, appModel.zoom.context) } // The transient strip's two dismissal inputs (`BoardSearchPresentation // .transientPersists`): it stays while a query is filtering the board or while the field @@ -761,7 +771,12 @@ struct BoardWindowHost: View { // accessory's reason exactly: it carries the store, and it is a board window's, not every // hosted window's. Its search item is the search field's home, and it is what tells // `boardSearch` whether that home still exists. - windowController.installToolbar(BoardToolbar.controller(store: store, search: boardSearch)) + windowController.installToolbar(BoardToolbar.controller( + store: store, + search: boardSearch, + zoom: appModel.zoom, + session: appModel.dragSession + )) } // MARK: - Closing diff --git a/Kanban/KanbanApp.swift b/Kanban/KanbanApp.swift index d602c1a..4b3b833 100644 --- a/Kanban/KanbanApp.swift +++ b/Kanban/KanbanApp.swift @@ -240,14 +240,22 @@ struct KanbanApp: App { ToolbarCommands() // The View menu. `CommandGroupPlacement.toolbar` *is* View — the menu the toolbar's own - // items live in — which is where 11-command-nexus.md files Show Trash. A divider separates it - // from the card window's three view-state rows below: one board-scoped toggle, then a - // card-scoped trio. + // items live in — which is where 11-command-nexus.md files Show Trash. Two dividers split it + // by scope, which is the only grouping the inventory implies: the board's toggle, then the + // board's zoom ladder, then the card window's view-state rows. + // + // "Zoom In" / "Zoom Out" / "Actual Size" rather than a single "Zoom": the system's own Window + // menu already carries a row titled Zoom, and titles are the remapping mechanism's key, so a + // second one would collide (the rule this file's own header states). CommandGroup(after: .toolbar) { ShowTrashCommand() Divider() + ZoomCommands(appModel: appModel) + + Divider() + CardViewCommands() } diff --git a/Kanban/LiveStore/BoardZoomStore.swift b/Kanban/LiveStore/BoardZoomStore.swift new file mode 100644 index 0000000..5a349e1 --- /dev/null +++ b/Kanban/LiveStore/BoardZoomStore.swift @@ -0,0 +1,103 @@ +import CoreGraphics +import Foundation +import Observation + +/// The board's zoom level: one number, app-wide, persisted (11-command-nexus.md ▸ View ▸ Actual Size +/// — "app-wide and persisted across restarts"; 03-board-ui.md ▸ Layout — zoom). +/// +/// `StyleRecents`' shape exactly, for its reasons. The *rules* — what the rungs are, how stepping +/// terminates, what an illegal stored value becomes — are `BoardZoom`'s pure functions; this object is +/// their persistence and their observability, and `defaults` is injectable so a test drives a suite of +/// its own rather than the user's. +/// +/// ### Why app-wide, and not per board +/// +/// A zoom level describes how this user likes to read, not what a board is. It has no business in a +/// lane's frontmatter (where `width` lives — that *is* board data, shared with every collaborator and +/// every agent), and no business in `BoardRegistry` either: a board opened on a laptop and on a studio +/// display wants the same *preference* applied, not a per-board memory of a window that no longer +/// exists. It sits beside Show Comments and Comments Beside Body, which are app-wide and persisted for +/// the same reason. +/// +/// ### Why `@Observable` rather than `@AppStorage` +/// +/// Two consumers need change notification that a property wrapper in a view cannot give them. The +/// board toolbar's validation is *observed*, not polled — `WindowToolbarController.trackValidationState` +/// re-arms `withObservationTracking` over each spec's `isEnabled`, so Zoom In greys out at the top rung +/// only if the level it reads is observable. And menu commands live outside every scene's environment, +/// so they receive `AppModel` as a plain `let` and re-render only because it is `@Observable`. One +/// observable holder serves both, and the level has exactly one home rather than a mirror per surface. +@MainActor +@Observable +public final class BoardZoomStore { + + /// The current rung — always a member of `BoardZoom.levels`, guaranteed by construction: the + /// initialiser normalises what it reads and `setLevel` normalises what it is given, so no reader + /// anywhere has to ask whether its level is legal. + public private(set) var level: CGFloat + + @ObservationIgnored + private let defaults: UserDefaults + + /// - Parameter defaults: the domain to persist in. Injected for `StyleRecents`' reason — a test + /// must be able to hold its own without touching the user's. + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + // `object(forKey:)` rather than `double(forKey:)` so an unset key arrives as nil rather than as + // 0, and `normalize` rather than `?? 1.0` so a hand-edited or stale value lands on a rung too. + // Either way the answer is legal; the distinction only decides *which* legal value an absent + // key becomes, and both roads lead to Actual Size. + level = BoardZoom.normalize((defaults.object(forKey: AppPreferences.boardZoomLevelKey) as? Double) ?? Double(BoardZoom.actualSize)) + } + + // MARK: - Moving + + /// Sets the level to the nearest legal rung and persists it. + /// + /// The single write path — Zoom In, Zoom Out, Actual Size and the two toolbar buttons all arrive + /// here, which is what keeps the menu row and its toolbar twin from being two implementations of + /// one command (the `BoardStore.setTrashVisible` precedent). + /// + /// **An unchanged level writes nothing and publishes nothing**, and unlike `StyleRecents.record`'s + /// unconditional write that guard is load-bearing rather than an optimisation. `@Observable` + /// notifies on *every* set, equal or not, so an ungated assignment would invalidate the board + /// window's zoom environment on a no-op — Actual Size when already at 100%, ⌘+ at the top rung — + /// and re-run the whole strip to draw exactly what it was drawing. A board nobody zoomed must not + /// pay for the feature existing. + /// + /// The *announcement* is deliberately not gated with it (`step(_:)`): a user who pressed ⌘+ at the + /// top rung is still owed the answer. + public func setLevel(_ newValue: CGFloat) { + let normalized = BoardZoom.normalize(Double(newValue)) + guard normalized != level else { return } + level = normalized + defaults.set(Double(normalized), forKey: AppPreferences.boardZoomLevelKey) + } + + /// One rung up; a fixed point at the top. + public func zoomIn() { setLevel(BoardZoom.stepIn(from: level)) } + + /// One rung down; a fixed point at the bottom. + public func zoomOut() { setLevel(BoardZoom.stepOut(from: level)) } + + /// Back to 100%, where the strip renders exactly what it rendered before zoom existed. + public func actualSize() { setLevel(BoardZoom.actualSize) } + + // MARK: - Reading + + public var canZoomIn: Bool { BoardZoom.canZoomIn(level) } + + public var canZoomOut: Bool { BoardZoom.canZoomOut(level) } + + public var isActualSize: Bool { BoardZoom.isActualSize(level) } + + /// The level as the strip's views take it. + /// + /// Internal rather than `public` like its neighbours, and the asymmetry is the type's, not an + /// oversight: `BoardZoomContext` is an environment value, which is a UI-layer concern the way + /// `dragSession` is — nothing outside this module has any business rendering a board. + var context: BoardZoomContext { BoardZoomContext(level: level) } + + /// "125%" — the announcement's value (10-accessibility.md ▸ Text scaling). + public var percentLabel: String { BoardZoom.percentLabel(level) } +} diff --git a/Kanban/UI/AccessibilityPhrases.swift b/Kanban/UI/AccessibilityPhrases.swift index 26f8f7c..bda4609 100644 --- a/Kanban/UI/AccessibilityPhrases.swift +++ b/Kanban/UI/AccessibilityPhrases.swift @@ -177,6 +177,18 @@ enum AccessibilityPhrases { shown ? "Trash shown" : "Trash hidden" } + /// What View ▸ Zoom In / Zoom Out / Actual Size announces — "a zoom change announces its new + /// level" (10-accessibility.md ▸ Text scaling). + /// + /// `trashVisibility`'s rule exactly, and for its reason: the resulting *state* rather than the + /// action, because three commands and two toolbar buttons all land on one ladder and what a user + /// needs to hear is which rung they are on — not that something moved. It is also the only signal + /// there is: nothing gains or loses focus, no element's label or value changes, and the whole + /// effect is a redraw a VoiceOver user cannot see. + static func zoomLevel(_ percent: String) -> String { + "Zoom \(percent)" + } + // MARK: - Live board announcements /// "3 lanes", "1 lane" — `cardCount`'s twin, and the second half of the digest's plural folding. diff --git a/Kanban/UI/Board/BoardDrops.swift b/Kanban/UI/Board/BoardDrops.swift index 5e8f154..c393ac7 100644 --- a/Kanban/UI/Board/BoardDrops.swift +++ b/Kanban/UI/Board/BoardDrops.swift @@ -64,6 +64,15 @@ final class LaneDropRegistry { } } + /// The board's current ruler, written by `BoardView` from the zoom environment (03-board-ui.md + /// ▸ Layout — zoom). + /// + /// It lives here for `stripFrame`'s reason: the drop delegates run at event time, outside any + /// body evaluation, so a number they need has to be *on* the registry rather than captured from a + /// view's last render. The default is the system size, which is what an un-zoomed board and every + /// test that never sets it get. + var bodyPointSize: CGFloat = BoardMetrics.bodyPointSize + /// The height a card with no registered measurement is assumed to have — a lane whose faces have /// not laid out yet. Nominal rather than zero, so the resting rows still tile. /// @@ -73,9 +82,12 @@ final class LaneDropRegistry { /// every un-measured slot boundary in the wrong place at a large system text size — and the drop /// model is forbidden from reading measured frames mid-flight (03-board-ui.md § Motion), so this /// guess is all it has until the lane lays out. - @MainActor - static var nominalCardHeight: CGFloat { - BoardMetrics.nominalCardHeight(bodyPointSize: BoardMetrics.bodyPointSize) + /// + /// An instance property rather than a static for exactly that reason extended to zoom: the guess + /// has to be made on the ruler the board is *currently* drawing with, and the level is a property + /// of the window this registry belongs to. + var nominalCardHeight: CGFloat { + BoardMetrics.nominalCardHeight(bodyPointSize: bodyPointSize) } /// The board strip's own frame in the window's SwiftUI global space — the origin the strip @@ -246,7 +258,7 @@ final class RestingLayoutCache { heights.reserveCapacity(lane.cards.count) for card in lane.cards where !hidden.contains(card.id) { cardIDs.append(card.id) - heights.append(registry.heights[card.id] ?? LaneDropRegistry.nominalCardHeight) + heights.append(registry.heights[card.id] ?? registry.nominalCardHeight) } let layout = LaneRestingLayout(cardIDs: cardIDs, heights: heights) @@ -449,7 +461,7 @@ struct BoardDropContext { heights: resting.heights, // The run's footprint at the landing spot: the first dragged card's frozen height, which // is the trigger rect the cursor is over (the rest stack below it). - draggedHeight: session.cardHeights.first ?? LaneDropRegistry.nominalCardHeight, + draggedHeight: session.cardHeights.first ?? registry.nominalCardHeight, current: session.laneProposal(onBoardRooted: store.rootKey, laneID: laneID) ) guard let slot else { return } // a dead region: hold @@ -661,7 +673,7 @@ struct BoardDropContext { } let rendered = lane.cards - let heights = rendered.map { registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight } + let heights = rendered.map { registry.heights[$0.id] ?? registry.nominalCardHeight } let count = FinderDrop.shadowCount(info.itemProviders(for: [.fileURL])) let landing = FileDropZones.landing( @@ -669,7 +681,7 @@ struct BoardDropContext { headerBottom: registry.headers[laneID]?.maxY, placement: grid.placement, heights: heights, - nominalHeight: LaneDropRegistry.nominalCardHeight, + nominalHeight: registry.nominalCardHeight, current: session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: laneID)?.index ) diff --git a/Kanban/UI/Board/BoardToolbar.swift b/Kanban/UI/Board/BoardToolbar.swift index eebf528..6126824 100644 --- a/Kanban/UI/Board/BoardToolbar.swift +++ b/Kanban/UI/Board/BoardToolbar.swift @@ -9,6 +9,8 @@ extension NSToolbarItem.Identifier { static let boardUndo = Self("board.undo") static let boardRedo = Self("board.redo") static let boardShowTrash = Self("board.showTrash") + static let boardZoomIn = Self("board.zoomIn") + static let boardZoomOut = Self("board.zoomOut") } // MARK: - The board window's toolbar @@ -20,8 +22,8 @@ extension NSToolbarItem.Identifier { /// "**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 +/// "**Catalog** (available via Customize): New Card, New Lane, Zoom In, Zoom Out …, 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" @@ -55,7 +57,21 @@ enum BoardToolbar { /// "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] { + /// - Parameters: + /// - zoom: the app-wide zoom level, in the catalog order the palette shows. It is not the + /// store's, unlike every other predicate here, because the level is not a board's + /// (03-board-ui.md ▸ Layout — zoom) — and it must be `@Observable` rather than read from + /// `UserDefaults` at build time, since `WindowToolbarController.trackValidationState` re-arms + /// observation over each spec's `isEnabled` and a plain scalar would leave Zoom In looking live + /// at the top rung. + /// - session: the app's drag session, for the same guard the menu rows carry + /// (`ZoomCommands.isEnabled`). + static func specs( + store: BoardStore, + search: BoardSearchPresentation, + zoom: BoardZoomStore, + session: DragSession + ) -> [ToolbarItemSpec] { [ .mirroring( menuTitle: "New Card", @@ -75,6 +91,34 @@ enum BoardToolbar { perform: { [weak store] in store?.createLane() } ) ), + // The zoom pair — plain buttons, because that is what the menu rows are. There is no + // percentage readout and no popup: a control that *displays* the level would need a + // custom view and a new `ToolbarItemSpec.Behavior` case, and the level already has a + // spoken voice (`AccessibilityPhrases.zoomLevel`) and a visible one (the board itself). + .mirroring( + menuTitle: "Zoom In", + identifier: .boardZoomIn, + symbol: "plus.magnifyingglass", + behavior: .button( + isEnabled: { [weak store, weak zoom, weak session] in + guard let zoom, let session else { return false } + return ZoomCommands.isEnabled(store: store, session: session) && zoom.canZoomIn + }, + perform: { [weak zoom] in zoom?.step(.in) } + ) + ), + .mirroring( + menuTitle: "Zoom Out", + identifier: .boardZoomOut, + symbol: "minus.magnifyingglass", + behavior: .button( + isEnabled: { [weak store, weak zoom, weak session] in + guard let zoom, let session else { return false } + return ZoomCommands.isEnabled(store: store, session: session) && zoom.canZoomOut + }, + perform: { [weak zoom] in zoom?.step(.out) } + ) + ), .staticLabel( "Undo", identifier: .boardUndo, @@ -132,10 +176,15 @@ enum BoardToolbar { /// 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 { + static func controller( + store: BoardStore, + search: BoardSearchPresentation, + zoom: BoardZoomStore, + session: DragSession + ) -> WindowToolbarController { let controller = WindowToolbarController( identifier: identifier, - specs: specs(store: store, search: search), + specs: specs(store: store, search: search, zoom: zoom, session: session), defaults: defaultItems ) // Centered against the window, not a flexible-space sandwich (03 ▸ Toolbar's placement diff --git a/Kanban/UI/Board/BoardView.swift b/Kanban/UI/Board/BoardView.swift index d43cdb3..8940e18 100644 --- a/Kanban/UI/Board/BoardView.swift +++ b/Kanban/UI/Board/BoardView.swift @@ -111,6 +111,11 @@ struct BoardView: View { /// `Accommodations.frost`). @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + /// **The board's ruler** (03-board-ui.md ▸ Layout — zoom; `BoardZoom`). Injected on this view by + /// `BoardWindowHost` and read all the way down the strip; every `BoardMetrics` figure below takes + /// `zoom.bodyPointSize` rather than the system's, which is the whole of what View ▸ Zoom In does. + @Environment(\.boardZoom) private var zoom + /// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored /// deliberately whenever an inline editor closes: the field that had focus is gone, and Return /// must go back to meaning create/rename rather than nothing at all. @@ -124,7 +129,7 @@ struct BoardView: View { /// size widens the gap and therefore *narrows* every lane, since the window's width still divides /// across `units + 1` gaps. The strip never grows and never scrolls; the lanes compress, "the /// degenerate case accepted, not floored" (03-board-ui.md § Layout — full visibility). - private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: BoardMetrics.bodyPointSize) } + private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: zoom.bodyPointSize) } var body: some View { // The strip's own body count (`BoardRenderMetrics`) — DEBUG only, and the discriminator @@ -160,6 +165,22 @@ struct BoardView: View { // rather than into `@State` so the drop delegates read it live at event time rather than // as of the last body evaluation. .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { laneDrops.stripFrame = $0 } + // The board's ruler, into the registry beside the strip's frame and for its reason: the + // drop delegates run at event time, outside any body, and `nominalCardHeight` — the + // stand-in they tile un-measured rows with — has to be computed on the zoom the board is + // actually drawing at (03-board-ui.md ▸ Layout — zoom). `initial: true` because the first + // render is already a level, not a change. + .onChange(of: zoom.bodyPointSize, initial: true) { + laneDrops.bodyPointSize = zoom.bodyPointSize + // And the resting layouts built on the old ruler go with it. `RestingLayoutCache`'s + // entry key is snapshot generation, heights generation, board root and hidden set — + // deliberately not the point size, because until zoom existed the point size could + // not move. This is what keeps that key honest rather than adding a fifth term to it: + // a level change is rare, a cache miss costs one rebuild, and `ZoomCommands` already + // holds the rows shut while a drag is in flight, so in practice there is nothing + // standing here to clear. + dropContext.session.restingLayouts.clear() + } // **The strip's drop target** — the backdrop, the gaps, the outer margin, and the trash // column's footprint, which is never a landing spot of its own (04-interactions.md ▸ The // trash) and so simply falls through to here. It accepts *every* session type — ours and @@ -324,7 +345,7 @@ struct BoardView: View { // One of the drag's N contiguous shadows, at the exact width the arriving lane // will occupy — its units measured against *this* strip's standard, which is // what makes the drop land precisely where the shadow shows. - DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize)) + DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize)) .frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)) .frame(maxHeight: .infinity) } @@ -568,7 +589,7 @@ struct BoardView: View { ZStack(alignment: .topLeading) { if resizing { DragShadow( - cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize), + cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: zoom.bodyPointSize), dashed: false ) .frame(width: slotWidth) diff --git a/Kanban/UI/Board/CardFaceView.swift b/Kanban/UI/Board/CardFaceView.swift index d882c80..e15a35d 100644 --- a/Kanban/UI/Board/CardFaceView.swift +++ b/Kanban/UI/Board/CardFaceView.swift @@ -146,10 +146,16 @@ struct CardFaceView: View, Equatable { /// `Accommodations`, which owns what "increased" does to a stroke. @Environment(\.colorSchemeContrast) private var contrast + /// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`). **The environment is what + /// makes zoom reach a card face at all**: this view is `.equatable()`, and the gate above compares + /// nothing that moves with the level — but it does not compare environment values either, because + /// SwiftUI invalidates on those itself. A level threaded any other way would be swallowed here. + @Environment(\.boardZoom) private var zoom + /// The live body metric — every figure this face lays out on is a multiple of it /// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule). Read here rather than /// passed in, which is `CardAttachmentsSection`'s pattern on the card-window side. - private var pointSize: CGFloat { BoardMetrics.bodyPointSize } + private var pointSize: CGFloat { zoom.bodyPointSize } /// The plate's corner radius — shared with the accent stripe, which rounds its left corners to /// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it. @@ -400,7 +406,7 @@ struct CardFaceView: View, Equatable { // The dragged items' sizes, frozen at drag start — the pickup transition scales the // replica, and its lingering "last measured frame" would mis-size the shadow and the // span-cap (03-board-ui.md § Motion). - heights: ordered.map { drops.registry.heights[$0] ?? LaneDropRegistry.nominalCardHeight }, + heights: ordered.map { drops.registry.heights[$0] ?? drops.registry.nominalCardHeight }, container: .board, source: store ) @@ -447,7 +453,7 @@ struct CardFaceView: View, Equatable { drops.session.beginCards( rows.map(\.id), folders: payload.folders, - heights: rows.map { drops.registry.heights[$0.id] ?? LaneDropRegistry.nominalCardHeight }, + heights: rows.map { drops.registry.heights[$0.id] ?? drops.registry.nominalCardHeight }, container: .trash, source: store, mixesKinds: mixesKinds @@ -492,7 +498,7 @@ struct CardFaceView: View, Equatable { .foregroundStyle(iconTint) .imageScale(.medium) Text(card.title.value ?? "Untitled") - .font(.body) + .boardFont(.body) .lineLimit(4) .frame(maxWidth: .infinity, alignment: .leading) attachmentsIndicator @@ -512,7 +518,7 @@ struct CardFaceView: View, Equatable { ) .background(RoundedRectangle(cornerRadius: cornerRadius).fill(BoardSurface.cardPlate)) .overlay(alignment: .leading) { accentStripe } - .dragReplicaShadow() + .dragReplicaShadow(zoom: zoom) } // MARK: - Context menus @@ -682,10 +688,10 @@ struct CardFaceView: View, Equatable { if case let .board(openCard) = role { openCard(id) } } ) - .font(.body) + .boardFont(.body) } else { Text(card.title.value ?? "Untitled") - .font(.body) + .boardFont(.body) .foregroundStyle(card.title.value == nil ? .secondary : .primary) .lineLimit(4) } @@ -715,7 +721,7 @@ struct CardFaceView: View, Equatable { private var attachmentsIndicator: some View { if !card.attachments.isEmpty { Image(systemName: "paperclip") - .font(.caption) + .boardFont(.caption) .foregroundStyle(.secondary) .accessibilityHidden(true) } diff --git a/Kanban/UI/Board/DragAutoScrollMath.swift b/Kanban/UI/Board/DragAutoScrollMath.swift index 4d8cd5a..dae4d93 100644 --- a/Kanban/UI/Board/DragAutoScrollMath.swift +++ b/Kanban/UI/Board/DragAutoScrollMath.swift @@ -50,22 +50,47 @@ enum DragAutoScrollMath { /// How far above the visible area the pointer may sit and still drive it — enough to cover the /// lane's header, which is where a drag naturally goes to scroll up. - static let reachAbove: CGFloat = 48 + /// + /// **Font-derived, unlike the three figures above it, and the split is the point.** `band`, + /// `minSpeed` and `maxSpeed` describe the *cursor's* relationship to an edge — how near counts as + /// near, how fast the content should answer — and none of that changes because the board is drawn + /// larger; they stay fixed for `LaneResizeSession.reentry`'s reason. The three reaches describe + /// **where the lane's furniture is**, and that furniture moves: this one is specified as "enough + /// to cover the lane's header", and a header at 200% zoom is twice as tall as the 48 points that + /// covered it at 13pt (03-board-ui.md ▸ Layout — zoom). + /// + /// 3.7 em: 48 points at the standard body size, which is what the reach has always been. + static func reachAbove(bodyPointSize: CGFloat) -> CGFloat { + BoardMetrics.em(3.7, bodyPointSize: bodyPointSize) + } - /// The same below, covering the lane's bottom padding. - static let reachBelow: CGFloat = 24 + /// The same below, covering the lane's bottom padding — 1.85 em, the standard size's 24 points. + static func reachBelow(bodyPointSize: CGFloat) -> CGFloat { + BoardMetrics.em(1.85, bodyPointSize: bodyPointSize) + } /// The sideways reach — kept under half the distance between two lanes' scroll areas so only /// one lane ever engages. - static let reachSide: CGFloat = 12 + /// + /// **It is the inter-lane gap, named as itself** rather than as a coincidentally equal number: + /// the distance between two lanes' scroll areas is the gap plus a plate padding on each side + /// (0.9 + 2 × 0.45 em), so half of it is exactly `stripGap`. Writing it that way is what keeps + /// the sentence above true at every zoom level instead of only at 13pt, where 12 happened to be + /// the answer. + static func reachSide(bodyPointSize: CGFloat) -> CGFloat { + BoardMetrics.stripGap(bodyPointSize: bodyPointSize) + } /// The region — in the visible area's own coordinates, `(0, 0)` at its top-left — a pointer /// must be in to drive this scroller at all. - static func engagementRect(viewport: CGSize) -> CGRect { - CGRect(x: -reachSide, - y: -reachAbove, - width: viewport.width + reachSide * 2, - height: viewport.height + reachAbove + reachBelow) + static func engagementRect(viewport: CGSize, bodyPointSize: CGFloat) -> CGRect { + let above = reachAbove(bodyPointSize: bodyPointSize) + let below = reachBelow(bodyPointSize: bodyPointSize) + let side = reachSide(bodyPointSize: bodyPointSize) + return CGRect(x: -side, + y: -above, + width: viewport.width + side * 2, + height: viewport.height + above + below) } /// Signed scroll velocity in points/second for a pointer at `position` along an axis whose diff --git a/Kanban/UI/Board/DragAutoScroller.swift b/Kanban/UI/Board/DragAutoScroller.swift index 084854f..9487c46 100644 --- a/Kanban/UI/Board/DragAutoScroller.swift +++ b/Kanban/UI/Board/DragAutoScroller.swift @@ -40,6 +40,20 @@ final class DragAutoScroller { /// Invoked after every scroll step. Re-resolves the drop proposal; see the type's note. fileprivate var didScroll: (() -> Void)? + /// The board's ruler, for the engagement rect's three reaches — they are distances to the lane's + /// own furniture, which moves with the zoom level (`DragAutoScrollMath.reachAbove`; + /// 03-board-ui.md ▸ Layout — zoom). + /// + /// Set by the lane at the head of each drag rather than observed, because that is exactly when it + /// can change: zoom is inert while a session is in flight (`ZoomCommands.isEnabled`), so one read + /// per drag is one read per level. + /// + /// Optional, and resolved at use rather than defaulted in `init`, because the initialiser is + /// `nonisolated` (the tick-source seam is constructed off the main actor in tests) and the system + /// body size is a main-actor read. Un-set means "nobody has told me", which resolves to the + /// system's ruler — an un-zoomed board's answer. + var bodyPointSize: CGFloat? + /// Where frames come from. One implementation ships (`DisplayLinkTickSource`); the seam exists /// because a `CADisplayLink` needs a screen and a run loop and so cannot tick in a test bundle /// (`DragAutoScrollDriverTests`). @@ -81,7 +95,9 @@ final class DragAutoScroller { let inWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation) let inClip = clip.convert(inWindow, from: nil) let pointer = CGPoint(x: inClip.x - visible.minX, y: inClip.y - visible.minY) - guard DragAutoScrollMath.engagementRect(viewport: visible.size).contains(pointer) else { return } + let ruler = bodyPointSize ?? BoardMetrics.bodyPointSize + guard DragAutoScrollMath.engagementRect(viewport: visible.size, bodyPointSize: ruler) + .contains(pointer) else { return } let velocity = DragAutoScrollMath.velocity(pointer: pointer, viewport: visible.size) guard velocity.dx != 0 || velocity.dy != 0 else { return } diff --git a/Kanban/UI/Board/DragShadow.swift b/Kanban/UI/Board/DragShadow.swift index 0aaeb7e..10e17c3 100644 --- a/Kanban/UI/Board/DragShadow.swift +++ b/Kanban/UI/Board/DragShadow.swift @@ -57,15 +57,19 @@ struct DragCountBadge: View { let count: Int + /// The board's ruler (`BoardZoom`) — the badge sits over the shadow of a card drawn at this zoom, + /// so it has to be sized on the same one. + @Environment(\.boardZoom) private var zoom + /// The badge is a disc around a numeral, so every figure in it follows the numeral's font /// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule) — at the standard body size /// they are the 6, 3 and 10 points it has always drawn. - private var pointSize: CGFloat { BoardMetrics.bodyPointSize } + private var pointSize: CGFloat { zoom.bodyPointSize } var body: some View { if count > 1 { Text("\(count)") - .font(.caption2.bold()) + .boardFont(.caption2, weight: .bold) .monospacedDigit() .foregroundStyle(.white) .padding(.horizontal, BoardMetrics.em(0.45, bodyPointSize: pointSize)) @@ -94,7 +98,19 @@ enum DragReplicaStyle { extension View { /// The pickup lift's shadow (`DragReplicaStyle`), for a drag replica's face — shared by the /// card, lane, and trashed-lane replicas so the figures are spelled once. - func dragReplicaShadow() -> some View { - shadow(color: DragReplicaStyle.shadowColor, radius: DragReplicaStyle.shadowRadius, y: DragReplicaStyle.shadowY) + /// + /// **It re-asserts the board's zoom, and that is not decoration.** A `.onDrag(_:preview:)` + /// preview is hosted for the drag *image*, outside the strip's view hierarchy, so the environment + /// the replica's own `.boardFont(_:)` modifiers resolve against is not reliably the board's. The + /// replica must be drawn on the same ruler as the face it was lifted from — otherwise the image + /// under the cursor is a card the board does not contain, which is the exact failure + /// `BoardMetrics.cardReplicaWidth(measured:)` exists to prevent on the width axis. Passing the + /// level in explicitly closes the type axis the same way. + /// + /// It rides on this modifier rather than being a fourth line in each replica for the reason the + /// shadow does: three replicas, one rule, spelled once. + func dragReplicaShadow(zoom: BoardZoomContext) -> some View { + environment(\.boardZoom, zoom) + .shadow(color: DragReplicaStyle.shadowColor, radius: DragReplicaStyle.shadowRadius, y: DragReplicaStyle.shadowY) } } diff --git a/Kanban/UI/Board/LaneResizeHandle.swift b/Kanban/UI/Board/LaneResizeHandle.swift index 656d6b2..7edb652 100644 --- a/Kanban/UI/Board/LaneResizeHandle.swift +++ b/Kanban/UI/Board/LaneResizeHandle.swift @@ -38,14 +38,18 @@ struct LaneResizeHandle: View { /// cursor. @State private var cursorPushed = false + /// The board's ruler (`BoardZoom`) — the gap this strip is proportioned against moves with the + /// level, so the grab target has to move with it too or it drifts off the gap it lives in. + @Environment(\.boardZoom) private var zoom + /// The grab strip's width and its rightward shift — both font-derived, because the inter-lane /// gap they are proportioned against is (`BoardMetrics.stripGap`, 10-accessibility.md's /// full-relative-scaling rule). At the standard body size they are the 12pt and 8pt the strip /// has always used: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt /// into the gap (clear of the lane's own scrollbar). - private var handleWidth: CGFloat { BoardMetrics.resizeHandleWidth(bodyPointSize: BoardMetrics.bodyPointSize) } + private var handleWidth: CGFloat { BoardMetrics.resizeHandleWidth(bodyPointSize: zoom.bodyPointSize) } - private var overhang: CGFloat { BoardMetrics.resizeHandleOverhang(bodyPointSize: BoardMetrics.bodyPointSize) } + private var overhang: CGFloat { BoardMetrics.resizeHandleOverhang(bodyPointSize: zoom.bodyPointSize) } var body: some View { Color.clear diff --git a/Kanban/UI/Board/LaneView.swift b/Kanban/UI/Board/LaneView.swift index b836235..eac351a 100644 --- a/Kanban/UI/Board/LaneView.swift +++ b/Kanban/UI/Board/LaneView.swift @@ -94,9 +94,14 @@ struct LaneView: View, Equatable { /// against the colours of the appearance the window is now in. @Environment(\.colorScheme) private var colorScheme + /// The board's ruler (03-board-ui.md ▸ Layout — zoom; `BoardZoom`), injected on the strip by + /// `BoardWindowHost`. An `@Environment` read rather than a value passed down deliberately: this + /// view is `.equatable()`, and environment values are the one input the gate cannot suppress. + @Environment(\.boardZoom) private var zoom + /// The live body metric — every figure this lane lays out on is a multiple of it - /// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule). - private var pointSize: CGFloat { BoardMetrics.bodyPointSize } + /// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule), at the board's zoom. + private var pointSize: CGFloat { zoom.bodyPointSize } /// Spacing between cards, and between the interior columns. private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) } @@ -476,10 +481,10 @@ struct LaneView: View, Equatable { // and open[s] the card window", and only a card has one to open). onCommitAndOpen: { store.commitRename() } ) - .font(.headline) + .boardFont(.headline) } else { Text(lane.title.value ?? "Untitled") - .font(.headline) + .boardFont(.headline) .foregroundStyle(lane.title.value == nil ? .secondary : .primary) .lineLimit(1) .truncationMode(.tail) @@ -502,7 +507,7 @@ struct LaneView: View, Equatable { /// body renders (see `renderedCards`). private var countBadge: some View { Text("\(renderedCards.count)") - .font(.caption) + .boardFont(.caption) .monospacedDigit() .foregroundStyle(.secondary) .padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize)) @@ -640,7 +645,7 @@ struct LaneView: View, Equatable { .foregroundStyle(.secondary) .imageScale(.medium) Text(card.title.value ?? "Untitled") - .font(.body) + .boardFont(.body) .lineLimit(2) Spacer(minLength: 0) } @@ -663,7 +668,7 @@ struct LaneView: View, Equatable { .background(lanePlate) .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background)) .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) - .dragReplicaShadow() + .dragReplicaShadow(zoom: zoom) } /// The replica's size — **the lane's own**, floored for a lane that has not measured itself yet @@ -841,6 +846,10 @@ struct LaneView: View, Equatable { // engagement rect simply scrolls nothing. .task(id: drops.session.isDraggingCards) { guard drops.session.isDraggingCards else { return } + // The engagement rect's reaches are distances to this lane's own furniture, so they are + // measured on the board's ruler (03-board-ui.md ▸ Layout — zoom). Read once at the head + // of the drag, which is once per level: zoom is inert while a session is in flight. + autoScroller.bodyPointSize = pointSize await autoScroller.run() } } @@ -867,7 +876,7 @@ struct LaneView: View, Equatable { if let proposal = drops.session.fileLaneProposal(onBoardRooted: store.rootKey, laneID: lane.id) { return ShadowRun( position: proposal.index, - heights: Array(repeating: LaneDropRegistry.nominalCardHeight, count: proposal.count) + heights: Array(repeating: drops.registry.nominalCardHeight, count: proposal.count) ) } return nil @@ -1162,9 +1171,13 @@ private struct NewCardStubView: View { /// Increase Contrast, for the editor well's stroke below (10-accessibility.md; `Accommodations`). @Environment(\.colorSchemeContrast) private var contrast + /// The board's ruler (`BoardZoom`) — the same environment the real face reads, so the placeholder + /// and the card that replaces it are drawn at one zoom. + @Environment(\.boardZoom) private var zoom + /// The live body metric — the same one the real face reads, which is what makes "the numbers are /// the same numbers rather than equal ones" survive the move to font-derived metrics. - private var pointSize: CGFloat { BoardMetrics.bodyPointSize } + private var pointSize: CGFloat { zoom.bodyPointSize } var body: some View { switch phase { @@ -1189,7 +1202,7 @@ private struct NewCardStubView: View { if let id = commit() { openCard(id) } } ) - .font(.body) + .boardFont(.body) .frame(maxWidth: .infinity, alignment: .leading) .padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize)) .background( @@ -1221,7 +1234,7 @@ private struct NewCardStubView: View { .foregroundStyle(.secondary) .imageScale(.medium) Text(store.transient.newCardPlaceholder?.draftTitle ?? "") - .font(.body) + .boardFont(.body) .lineLimit(4) .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/Kanban/UI/Board/TrashLaneRowView.swift b/Kanban/UI/Board/TrashLaneRowView.swift index b52e2fc..4abeefd 100644 --- a/Kanban/UI/Board/TrashLaneRowView.swift +++ b/Kanban/UI/Board/TrashLaneRowView.swift @@ -60,7 +60,11 @@ struct TrashLaneRowView: View { /// selected card wear the same ring at the same strength. @Environment(\.colorSchemeContrast) private var contrast - private var pointSize: CGFloat { BoardMetrics.bodyPointSize } + /// The board's ruler (`BoardZoom`) — `CardFaceView`'s rule again, so a trash row and a card face + /// are drawn on one scale. + @Environment(\.boardZoom) private var zoom + + private var pointSize: CGFloat { zoom.bodyPointSize } /// The card plate's radius: the rows sit in one column and a row with a different corner would /// read as a different *kind of surface* rather than as a different kind of row. What @@ -95,7 +99,7 @@ struct TrashLaneRowView: View { .foregroundStyle(.secondary) .imageScale(.medium) Text(lane.title.value ?? AccessibilityPhrases.untitled) - .font(.body) + .boardFont(.body) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) @@ -159,7 +163,7 @@ struct TrashLaneRowView: View { /// furniture, and this row is deliberately not one. private var heldCount: some View { Text(AccessibilityPhrases.cardCount(lane.heldCards)) - .font(.caption) + .boardFont(.caption) .monospacedDigit() .foregroundStyle(.tertiary) // Folded into the flattened element's label above, like the card face's chips. @@ -264,12 +268,12 @@ struct TrashLaneRowView: View { .foregroundStyle(.secondary) .imageScale(.medium) Text(lane.title.value ?? AccessibilityPhrases.untitled) - .font(.body) + .boardFont(.body) .foregroundStyle(.secondary) .lineLimit(1) .frame(maxWidth: .infinity, alignment: .leading) Text(AccessibilityPhrases.cardCount(lane.heldCards)) - .font(.caption) + .boardFont(.caption) .monospacedDigit() .foregroundStyle(.tertiary) } @@ -279,7 +283,7 @@ struct TrashLaneRowView: View { alignment: .leading ) .background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.tertiary)) - .dragReplicaShadow() + .dragReplicaShadow(zoom: zoom) } // MARK: - The row's two rows of menu diff --git a/Kanban/UI/Board/TrashLaneView.swift b/Kanban/UI/Board/TrashLaneView.swift index e724829..aa448da 100644 --- a/Kanban/UI/Board/TrashLaneView.swift +++ b/Kanban/UI/Board/TrashLaneView.swift @@ -113,10 +113,14 @@ struct TrashLaneView: View { /// light/dark flip recomputes the header's ink (`LaneView.colorScheme`'s twin, for its reason). @Environment(\.colorScheme) private var colorScheme + /// The board's ruler (`BoardZoom`) — the same one the live lanes read, since the trash column has + /// to stay their sibling at every zoom level as well as at every text size. + @Environment(\.boardZoom) private var zoom + /// The live body metric — this column's geometry is `LaneView`'s, derived from the same font /// (`BoardMetrics`), because these are the same cards in a column that must read as their /// sibling. - private var pointSize: CGFloat { BoardMetrics.bodyPointSize } + private var pointSize: CGFloat { zoom.bodyPointSize } /// The lane plate's corner radius — matched to `LaneView`'s so the column reads as a sibling of /// the lanes rather than as a different kind of object. @@ -235,7 +239,7 @@ struct TrashLaneView: View { .foregroundStyle(.secondary) .imageScale(.medium) Text("Trash") - .font(.headline) + .boardFont(.headline) .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.tail) @@ -292,7 +296,7 @@ struct TrashLaneView: View { /// the difference between a card and a lane's freight actually matters. private var countBadge: some View { Text("\(renderedRows.count)") - .font(.caption) + .boardFont(.caption) .monospacedDigit() .foregroundStyle(.secondary) .padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize)) @@ -360,7 +364,7 @@ struct TrashLaneView: View { // being proposed have no face here yet to be measured. DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize)) .frame(maxWidth: .infinity) - .frame(height: LaneDropRegistry.nominalCardHeight) + .frame(height: drops.registry.nominalCardHeight) } } // A slot is a row in this column, so it arrives and leaves in the card's dialect diff --git a/Kanban/UI/Board/ZoomCommands.swift b/Kanban/UI/Board/ZoomCommands.swift new file mode 100644 index 0000000..fa76697 --- /dev/null +++ b/Kanban/UI/Board/ZoomCommands.swift @@ -0,0 +1,110 @@ +import SwiftUI + +// MARK: - View ▸ Zoom In / Zoom Out / Actual Size + +/// The board's three zoom rows (11-command-nexus.md ▸ View; behaviour in 03-board-ui.md ▸ Layout — +/// zoom). +/// +/// They step one app-wide level (`BoardZoomStore`), which every open board window reads through the +/// environment, so this is deliberately *not* a per-window command: ⌘+ in one window zooms the board +/// in the other too, the way Show Comments checks in every card window at once. +/// +/// ### Why they are scoped to a board window anyway +/// +/// The level is app-wide but the *surface* it moves is not: only board windows zoom (the card window +/// is a later milestone). A ⌘+ that changed nothing visible while a card window was frontmost would +/// be a command that silently missed, so `@FocusedValue(\.boardStore)` scopes the rows the way every +/// other board command is scoped — present and live over a board, disabled everywhere else. +/// +/// ### The three disabled states +/// +/// Each end of the ladder disables its own direction and Actual Size disables at 100%: "an item whose +/// only outcome is a no-op reads better disabled than dead" (`LaneWidthCommands.canDecrease`). +/// +/// **All three also disable while a drag session is in flight**, which is an invariant stated rather +/// than a defence. A card or lane drag freezes geometry the level feeds — the drag's frozen card +/// heights, and `RestingLayoutCache`, whose entry key does not include the point size — so a level +/// that moved underneath one would leave the proposal resolving against a layout the board is no +/// longer drawing. In practice an AppKit drag loop swallows key equivalents and no toolbar button can +/// be clicked with the mouse already down, so the guard should never fire; `BoardView` clears the +/// resting layouts on a level change regardless, which is what actually makes the case safe. This is +/// the honest statement of the rule, and the thing a test can hold +/// (`ShowCommentsCommand.isEnabled`'s pattern). +/// +/// The read-only lock is deliberately absent, for `ShowTrashCommand`'s reason: zooming is a view +/// change, not a mutation, and a locked board is exactly when a user wants to read it more +/// comfortably. +struct ZoomCommands: View { + + /// The app-wide level. A plain `let` rather than an `@Environment` read because menu commands live + /// in the menu bar, outside every scene's environment; it re-renders on a level change because + /// `AppModel` is `@Observable` (`NewBoardCommand`'s pattern). + let appModel: AppModel + + @FocusedValue(\.boardStore) private var store + + var body: some View { + Button("Zoom In") { appModel.zoom.step(.in) } + .keyboardShortcut("+", modifiers: .command) + .disabled(!Self.isEnabled(store: store, session: appModel.dragSession) || !appModel.zoom.canZoomIn) + + Button("Zoom Out") { appModel.zoom.step(.out) } + .keyboardShortcut("-", modifiers: .command) + .disabled(!Self.isEnabled(store: store, session: appModel.dragSession) || !appModel.zoom.canZoomOut) + + Button("Actual Size") { appModel.zoom.step(.actualSize) } + .keyboardShortcut("0", modifiers: .command) + .disabled(!Self.isEnabled(store: store, session: appModel.dragSession) || appModel.zoom.isActualSize) + } + + /// Whether the rows apply at all — a board window in front, and no drag in flight. + /// + /// Extracted as a static function so the rule is assertable without driving a menu + /// (`ShowCommentsCommand.isEnabled`, `SaveAsTemplateCommand.allowsSave`). It deliberately does + /// *not* fold in the per-direction ladder ends: those are properties of the level, already stated + /// on `BoardZoom`, and duplicating them here would be two answers to one question. + static func isEnabled(store: BoardStore?, session: DragSession) -> Bool { + store != nil && !session.isActive + } +} + +// MARK: - The move, and the one place it happens + +/// Which way a zoom command goes — the three rows' whole difference from one another. +/// +/// An enum rather than three methods so `BoardZoomStore.step(_:)` can be the single write path the +/// menu rows *and* the toolbar buttons share, the way `BoardStore.setTrashVisible` is Show Trash's +/// (03-board-ui.md ▸ Toolbar: a toolbar item is a menu command with a different face, never a second +/// implementation of it). +enum ZoomMove { + case `in` + case out + case actualSize +} + +extension BoardZoomStore { + + /// A zoom command's whole behaviour — move the level, animate the board under it, say the new + /// level out loud. + func step(_ move: ZoomMove) { + // A user-initiated structural change, so it animates in the structural voice and goes instant + // under Reduce Motion (03-board-ui.md § Motion: "everything the user does through the app … + // lands in an animated transaction regardless of entry point"). + // + // Reduce Motion read from AppKit rather than from `@Environment` for `setTrashVisible`'s + // reason: a menu command's content is built outside any rendered hierarchy, and a toolbar item + // has no environment at all. + withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) { + switch move { + case .in: zoomIn() + case .out: zoomOut() + case .actualSize: actualSize() + } + } + // Announced from here rather than from each row, so the two toolbar buttons say the same + // sentence as the menu (10-accessibility.md ▸ Text scaling). Unconditional even when the level + // did not move: a user who pressed ⌘+ at the top rung is owed the answer "still 200%", and a + // silent no-op is the one response that reads as a broken command. + AccessibilityAnnouncer.post(AccessibilityPhrases.zoomLevel(percentLabel)) + } +} diff --git a/Kanban/UI/BoardZoom.swift b/Kanban/UI/BoardZoom.swift new file mode 100644 index 0000000..bdc6699 --- /dev/null +++ b/Kanban/UI/BoardZoom.swift @@ -0,0 +1,247 @@ +import AppKit +import CoreGraphics +import SwiftUI + +/// The board's **zoom ladder** — the multiplier macOS never supplies, applied to the one scalar the +/// whole strip is already derived from (03-board-ui.md ▸ Layout — "Zoom scales the ruler, never the +/// strip"; 10-accessibility.md ▸ Text scaling). +/// +/// `Motion` and `Accommodations`' sibling, and deliberately the same shape: pure functions over a +/// value, nothing rendered, nothing stored. `BoardZoomStore` owns the level and persists it; this +/// owns what a level *means*, so every claim below is assertable without driving a view +/// (`BoardZoomTests`). +/// +/// ### Why this feature is arithmetic rather than a magnification +/// +/// `BoardMetrics` is twenty-two `em(multiple, bodyPointSize:)` functions and one impure read of what +/// the body point size is, because 10-accessibility.md committed the board to full relative scaling: +/// "relative text styles everywhere, no fixed point sizes … so layout survives the largest system +/// text sizes". On macOS that commitment has never once been exercised — there is no system text-size +/// setting, so `NSFont.preferredFont(forTextStyle: .body).pointSize` is 13 forever. Zoom is that +/// machinery's first consumer: a rung on the ladder multiplies the point size, and the twenty-two +/// functions carry it to every gap, radius, inset and stripe on the board. +/// +/// A `scaleEffect` or an `NSScrollView.magnification` was the other candidate and is rejected on two +/// counts. It would have to widen the strip, which means horizontal scroll — refused by +/// 03-board-ui.md ▸ Layout, and refused in the pathfinder before it. And it would scale the +/// coordinate space that `onGeometryChange` reports frames in while leaving `NSEvent.mouseLocation` +/// alone, silently desynchronising every drop registry from the cursor. +/// +/// ### What the level does not touch +/// +/// **Lane width.** The window's width divides across the lanes' width units and that is the whole of +/// the resting layout (`LaneLayoutMath.standardWidth`), so a rung moves the inter-lane gap — an em +/// multiple like everything else — and nothing more. Lanes narrow by a few percent across the +/// ladder's whole range; cards inside them grow. That asymmetry *is* the feature on a board that +/// refuses horizontal scroll: zoom in for bigger, more legible cards and fewer per screen. +/// +/// **The window.** `BoardMetrics.windowMinimumSize` stays pinned to the system body size, because +/// 03-board-ui.md ▸ Lane rules that moving the window belongs to the right-edge drag alone. +enum BoardZoom { + + // MARK: - The ladder + + /// The rungs, ascending. Zoom In and Zoom Out step between adjacent entries; there is no + /// continuous level, and nothing off this list is reachable through the UI. + /// + /// Discrete rather than a percentage field for the reason every zoom control on the platform is: + /// the interesting question is "a bit bigger", not "117%", and a ladder makes ⌘+ ⌘+ ⌘− land back + /// exactly where it started. The spacing widens as it climbs — 0.10 near the default, 0.25 at the + /// top — because a fixed step reads as too coarse small and too fine large. + /// + /// The floor is 0.75 (≈10pt body text) rather than something smaller: below that the card face's + /// title stops being readable at arm's length and the board stops being a board. The ceiling is + /// 2.0, where a standard lane still fits a card with room for its title. + static let levels: [CGFloat] = [0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0] + + /// The rung Actual Size returns to, and the one every metric on the board was tuned against. + /// + /// **The default renders pixel-for-pixel what it rendered before zoom existed** — `bodyPointSize` + /// below multiplies by exactly 1 and `font` hands back the relative style untouched. That is the + /// same guarantee `BoardMetrics`' em multiples were chosen under ("this milestone is meant to make + /// the board *scale*, not to redesign it"), extended to the control that moves them. + static let actualSize: CGFloat = 1.0 + + /// The next rung up, or `level` itself at the top — a fixed point rather than a wrap, so a held + /// ⌘+ settles instead of cycling. + static func stepIn(from level: CGFloat) -> CGFloat { + levels.first { $0 > level } ?? levels[levels.count - 1] + } + + /// The next rung down, or `level` itself at the bottom. + static func stepOut(from level: CGFloat) -> CGFloat { + levels.last { $0 < level } ?? levels[0] + } + + static func canZoomIn(_ level: CGFloat) -> Bool { level < levels[levels.count - 1] } + + static func canZoomOut(_ level: CGFloat) -> Bool { level > levels[0] } + + /// Whether Actual Size would change anything. + static func isActualSize(_ level: CGFloat) -> Bool { level == actualSize } + + // MARK: - Reading a stored level + + /// An arbitrary stored `Double` mapped onto a legal rung — clamped to the ladder's ends and + /// snapped to the nearest entry. + /// + /// Every read of the persisted level goes through this, and it is not defensive decoration. The + /// preference is a plain `UserDefaults` key: a user can `defaults write` it to anything, a future + /// build can shorten the ladder under a value written by an older one, and `double(forKey:)` + /// answers **0 for a key that was never set** — which, unfiltered, would drive every em multiple + /// to its 1pt floor and draw a board of hairlines. Nothing downstream should have to ask whether + /// its level is real. + /// + /// Non-finite input (NaN, infinity) resolves to `actualSize` rather than to an end of the ladder: + /// there is no honest nearest rung to a value that is not a number, and the default is the only + /// answer that cannot surprise. + static func normalize(_ stored: Double) -> CGFloat { + guard stored.isFinite else { return actualSize } + let value = CGFloat(stored) + guard let nearest = levels.min(by: { abs($0 - value) < abs($1 - value) }) else { return actualSize } + return nearest + } + + // MARK: - What a level means + + /// The body point size the board draws at: the system's, times the level. + /// + /// **Deliberately not rounded.** `BoardMetrics.em` already rounds every figure it produces to a + /// whole point and floors it at one, so rounding here would round twice — and the two roundings + /// disagree (`round(round(13 × 1.15) × 0.9)` is not `round(13 × 1.15 × 0.9)`). One rule, applied + /// where it already lives: the level scales, `em` rounds. + /// + /// Floored at 1 for the same reason `em` is: no proposal downstream may be zero or negative, and + /// a level cannot be trusted to be positive until `normalize` has seen it. + static func bodyPointSize(system: CGFloat, level: CGFloat) -> CGFloat { + max(1, system * level) + } + + /// A text style at this level — **the relative style itself at Actual Size**, a fixed size scaled + /// off it anywhere else. + /// + /// The early return is the whole reason this function exists rather than an unconditional + /// `.system(size:)`. A relative style carries more than a number — the system's own leading and + /// its accessibility traits ride along with it — and a user who never touches zoom should not pay + /// for the feature by having every label on the board silently swapped for a point size. So the + /// default rung keeps the semantic style verbatim and only a deliberate zoom trades it away. + /// + /// Off the default, every style scales by the same factor, so the type hierarchy the board + /// encodes — `.headline` over `.body` over `.caption` — survives the trip intact. + @MainActor + static func font(_ style: Font.TextStyle, level: CGFloat, weight: Font.Weight? = nil) -> Font { + guard level != actualSize else { return .system(style, weight: weight) } + let system = NSFont.preferredFont(forTextStyle: style.appKitStyle).pointSize + return .system(size: system * level, weight: weight) + } + + /// The level as a percentage, for the announcement a zoom owes VoiceOver + /// (10-accessibility.md ▸ Text scaling). + static func percentLabel(_ level: CGFloat) -> String { + "\(Int((level * 100).rounded()))%" + } +} + +// MARK: - The environment + +/// The zoom level as the strip's views see it — **the app's one custom environment value**. +/// +/// The environment rather than a read of the store inside each `body`, and the reason is specific +/// enough to be worth stating: `CardFaceView` and `LaneView` are `.equatable()`, and +/// `CardFaceView`'s own note says `@Environment` values are "deliberately NOT compared" because +/// "SwiftUI invalidates on those itself". A level threaded through the environment therefore +/// propagates *through* the render gates for free. A level read from a singleton or mirrored into +/// `@State` would be swallowed by them on every card face — the board would zoom everywhere except +/// the cards. +/// +/// The value carries the level rather than the multiplied point size so it stays `Sendable` and +/// non-isolated (the environment's default must be constructible without touching AppKit); the +/// system half is read on demand below, exactly as `BoardMetrics.bodyPointSize` reads it. +struct BoardZoomContext: Equatable, Sendable { + + var level: CGFloat + + /// The unzoomed board — the environment's default, and what any view rendered outside a board + /// window's injection gets. Unzoomed is the only honest fallback: it is what the board drew + /// before this file existed. + static let actualSize = BoardZoomContext(level: BoardZoom.actualSize) + + /// The number every `BoardMetrics` function takes. + /// + /// **This, not `BoardMetrics.bodyPointSize`, is what the strip asks.** The two are deliberately + /// different readings and the distinction is load-bearing: `BoardMetrics.bodyPointSize` is the + /// *system's* body size, which is still what the window minimum, the banner strip, the sheets and + /// popovers, the welcome window and the whole card window want — none of them zoom (03-board-ui.md + /// ▸ Layout: the level is the board's, and a sheet is a form, not the board). + @MainActor + var bodyPointSize: CGFloat { + BoardZoom.bodyPointSize(system: BoardMetrics.bodyPointSize, level: level) + } + + @MainActor + func font(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> Font { + BoardZoom.font(style, level: level, weight: weight) + } +} + +extension EnvironmentValues { + /// Injected once, on the board window's content (`BoardWindowHost`). Everything under it — lanes, + /// card faces, the trash column, drag shadows, the resize handle — reads its ruler from here. + @Entry var boardZoom: BoardZoomContext = .actualSize +} + +extension View { + + /// A text style at the board's current zoom — the strip's replacement for `.font(_:)`. + /// + /// Every `Text` inside the strip wears this instead of a bare relative style, because the style + /// alone does not move: SwiftUI resolves `.caption` against the system, which zoom does not + /// change. Chrome outside the strip keeps using `.font(_:)` and stays at system size on purpose. + /// + /// SF Symbols need no equivalent — `Image(systemName:)` under `.imageScale(_:)` sizes off the + /// current font, which is why `BoardMetrics.newCardButtonReserve` is 1.7 em rather than 22pt. + func boardFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View { + modifier(BoardFontModifier(style: style, weight: weight)) + } +} + +private struct BoardFontModifier: ViewModifier { + + @Environment(\.boardZoom) private var zoom + + let style: Font.TextStyle + let weight: Font.Weight? + + func body(content: Content) -> some View { + content.font(zoom.font(style, weight: weight)) + } +} + +// MARK: - The style tables + +private extension Font.TextStyle { + + /// SwiftUI's text style as AppKit names it, so `NSFont.preferredFont(forTextStyle:)` can be asked + /// what point size it resolves to. + /// + /// A table rather than a rawValue bridge because there is no bridge: the two enumerations agree on + /// most names and disagree on three — SwiftUI's `.title` is AppKit's `.title1`, its `.caption` is + /// `.caption1`, and the sizes SwiftUI adds beyond AppKit's list have nowhere to land. Those fall + /// back to `.body`, which is the closest AppKit *has*; none of them appears on the board. + var appKitStyle: NSFont.TextStyle { + switch self { + case .largeTitle: .largeTitle + case .title: .title1 + case .title2: .title2 + case .title3: .title3 + case .headline: .headline + case .subheadline: .subheadline + case .body: .body + case .callout: .callout + case .footnote: .footnote + case .caption: .caption1 + case .caption2: .caption2 + @unknown default: .body + } + } +} diff --git a/KanbanTests/BoardRenderPerformanceTests.swift b/KanbanTests/BoardRenderPerformanceTests.swift index 43f0135..d183482 100644 --- a/KanbanTests/BoardRenderPerformanceTests.swift +++ b/KanbanTests/BoardRenderPerformanceTests.swift @@ -87,6 +87,36 @@ private func makeFixture(lanes laneCount: Int = laneCount, cards cardsPerLane: I // MARK: - Hosting +/// `BoardWindowHost`'s one relevant job, reproduced: read the app-wide zoom level and inject it as +/// the strip's ruler (03-board-ui.md ▸ Layout — zoom). +/// +/// A wrapper rather than a `.environment(\.boardZoom, …)` on the hosted root, because the modifier's +/// argument is evaluated once when the root value is built and an `NSHostingView`'s root is a stored +/// value. Reading `appModel.zoom` inside a `body` is what makes a level change re-run this view and +/// therefore re-publish the environment — which is exactly the propagation path the real window uses, +/// and exactly what the zoom invariant below is a claim about. +private struct ZoomedBoard: View { + + let store: BoardStore + let window: @MainActor () -> NSWindow? + let confirmations: TrashConfirmations + let openCard: (ItemID) -> Void + let search: BoardSearchPresentation + + @Environment(AppModel.self) private var appModel + + var body: some View { + BoardView( + store: store, + window: window, + confirmations: confirmations, + openCard: openCard, + search: search + ) + .environment(\.boardZoom, appModel.zoom.context) + } +} + /// Everything the hosted board needs to stay alive for the length of a test — an `NSHostingView` /// whose window is released the moment nothing holds it would stop rendering mid-measurement. @MainActor @@ -97,19 +127,26 @@ private final class HostedBoard { let view: NSView private let scratch: URL + /// The preferences domain the model's app-wide state persists into — redirected for + /// `registryStorageURL`'s reason. The zoom level lives here, and a suite that used `.standard` + /// would leave the developer's own boards zoomed. + private let preferencesDomain: String + init(store: BoardStore, scratch: URL) { self.store = store self.scratch = scratch + preferencesDomain = "dev.rzen.indie.Kanban.render-perf.\(UUID().uuidString)" appModel = AppModel( registryStorageURL: scratch.appendingPathComponent("board-registry.json"), - clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true) + clipboardStagingRoot: scratch.appendingPathComponent("Clipboard", isDirectory: true), + preferences: UserDefaults(suiteName: preferencesDomain)! ) let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1600, height: 1000), styleMask: [.titled], backing: .buffered, defer: false ) self.window = window - let root = BoardView( + let root = ZoomedBoard( store: store, window: { [weak window] in window }, confirmations: TrashConfirmations(), @@ -131,6 +168,7 @@ private final class HostedBoard { window.orderOut(nil) window.contentView = nil try? FileManager.default.removeItem(at: scratch) + UserDefaults.standard.removePersistentDomain(forName: preferencesDomain) } /// Pumps the main run loop until SwiftUI has flushed its pending updates and laid the tree out. @@ -359,4 +397,63 @@ struct BoardRenderPerformanceTests { // this card's. See RENDER-INSTRUMENTATION.md ▸ What the first run found. #expect(selected.strips >= 1, "the strip did not re-run for a selection change") } + + /// **The zoom feature's load-bearing render claim** (03-board-ui.md ▸ Layout — zoom). + /// + /// `CardFaceView` is `.equatable()` and its `==` compares nothing that moves with the zoom level: + /// the card, its role, its store, the marquee and the drop context — all identical across a + /// ⌘+. The level reaches the faces *only* because it travels in the environment, which + /// `CardFaceView`'s own note says the gate deliberately does not compare ("SwiftUI invalidates on + /// those itself"). + /// + /// That makes this the one assertion standing between the feature and a board that zooms its + /// lanes while every card face stays 13pt — a failure that would look like a rendering glitch and + /// actually be an architecture decision quietly coming undone. A zero here is the whole bug. + @Test("A zoom change repaints the card faces, through the equality gate") + func zoomRepaintsTheCardFaces() throws { + let fixture = try makeFixture() + defer { fixture.tearDown() } + let board = try host(fixture) + + BoardRenderMetrics.reset() + board.appModel.zoom.step(.in) + board.settle() + let zoomed = Cost() + + print("── zoom in one rung — \(zoomed.summary)") + + #expect(board.appModel.zoom.level != BoardZoom.actualSize, "the level did not actually move") + #expect(zoomed.cards > 0, "a zoom change repainted no card faces — the equality gate swallowed it") + #expect(zoomed.containers > 0, "a zoom change repainted no lanes") + #expect(zoomed.strips >= 1, "the strip did not re-run for a zoom change") + // The masonry's own cache is keyed on column width, which moves with the card spacing, so a + // level change must also cost a re-measure rather than replay stale heights. + #expect(zoomed.measures > 0, "the masonry replayed heights measured on the old ruler") + } + + /// The counter-invariant: Actual Size costs what it always did. A board nobody zooms must not pay + /// for the feature existing — the steady state after a reload is still the numbers the two + /// invariants above pin, with the ruler sitting on the system's own body size. + @Test("At Actual Size the strip renders on the system's own ruler") + func actualSizeIsTheUntouchedBoard() throws { + let fixture = try makeFixture() + defer { fixture.tearDown() } + let board = try host(fixture) + + #expect(board.appModel.zoom.isActualSize, "a fresh domain must open unzoomed") + #expect(board.appModel.zoom.context.bodyPointSize == BoardMetrics.bodyPointSize) + + BoardRenderMetrics.reset() + board.appModel.zoom.step(.actualSize) + board.settle() + let resettled = Cost() + + print("── Actual Size when already there — \(resettled.summary)") + + // `BoardZoomStore.setLevel` refuses an unchanged level outright — `@Observable` notifies on + // every set, equal or not, so without that guard re-asserting the level the board is already + // at would re-run the whole strip to draw exactly what it was drawing. + #expect(resettled.cards == 0, "a no-op Actual Size repainted \(resettled.cards) card faces") + #expect(resettled.containers == 0, "a no-op Actual Size repainted \(resettled.containers) lanes") + } } diff --git a/KanbanTests/BoardZoomTests.swift b/KanbanTests/BoardZoomTests.swift new file mode 100644 index 0000000..90043f9 --- /dev/null +++ b/KanbanTests/BoardZoomTests.swift @@ -0,0 +1,428 @@ +import CoreGraphics +import Foundation +import SwiftUI +import Testing +@testable import Kanban + +/// A zoom store on a scratch defaults domain — the level is app-wide and persisted, so a suite that +/// used `.standard` would zoom the developer's own boards. +@MainActor +private func makeStore(seeding stored: Double? = nil) -> (BoardZoomStore, UserDefaults, () -> Void) { + let name = "dev.rzen.indie.Kanban.zoom-tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + if let stored { defaults.set(stored, forKey: AppPreferences.boardZoomLevelKey) } + return (BoardZoomStore(defaults: defaults), defaults, + { UserDefaults.standard.removePersistentDomain(forName: name) }) +} + +/// The standard system body size. Written down rather than read, so every claim below is a fact about +/// the arithmetic and not about the machine the suite happens to run on. +private let standardBody: CGFloat = 13 + +// MARK: - The ladder + +@Suite("Zoom ▸ the ladder") +struct BoardZoomLadderTests { + + @Test("The rungs ascend, and 100% is one of them") + func rungsAscend() { + #expect(BoardZoom.levels == BoardZoom.levels.sorted()) + #expect(Set(BoardZoom.levels).count == BoardZoom.levels.count, "no rung appears twice") + #expect(BoardZoom.levels.contains(BoardZoom.actualSize), "Actual Size must be reachable by stepping") + #expect(BoardZoom.levels.allSatisfy { $0 > 0 }) + } + + /// A held ⌘+ must settle rather than cycle, so both ends are fixed points. + @Test("Stepping walks the ladder and stops at each end") + func steppingTerminates() { + var level = BoardZoom.levels.first! + for expected in BoardZoom.levels.dropFirst() { + level = BoardZoom.stepIn(from: level) + #expect(level == expected) + } + #expect(BoardZoom.stepIn(from: level) == level, "the top rung is a fixed point") + + for expected in BoardZoom.levels.dropLast().reversed() { + level = BoardZoom.stepOut(from: level) + #expect(level == expected) + } + #expect(BoardZoom.stepOut(from: level) == level, "the bottom rung is a fixed point") + } + + /// ⌘+ then ⌘− lands back exactly where it started — the whole reason the ladder is discrete + /// rather than a percentage field. + @Test("In then out is a round trip from every interior rung") + func inThenOutRoundTrips() { + for level in BoardZoom.levels.dropFirst().dropLast() { + #expect(BoardZoom.stepOut(from: BoardZoom.stepIn(from: level)) == level) + #expect(BoardZoom.stepIn(from: BoardZoom.stepOut(from: level)) == level) + } + } + + @Test("The can-step predicates agree with what stepping actually does") + func predicatesMatchStepping() { + for level in BoardZoom.levels { + #expect(BoardZoom.canZoomIn(level) == (BoardZoom.stepIn(from: level) != level)) + #expect(BoardZoom.canZoomOut(level) == (BoardZoom.stepOut(from: level) != level)) + } + #expect(!BoardZoom.canZoomIn(BoardZoom.levels.last!)) + #expect(!BoardZoom.canZoomOut(BoardZoom.levels.first!)) + #expect(BoardZoom.isActualSize(BoardZoom.actualSize)) + } +} + +// MARK: - Reading a stored level + +/// `normalize` is the one gate every persisted level passes through, and the reason it exists is that +/// the preference is a plain `UserDefaults` scalar a user can `defaults write` to anything. +@Suite("Zoom ▸ normalising a stored level") +struct BoardZoomNormalizeTests { + + @Test("Every rung survives a round trip") + func rungsAreFixedPoints() { + for level in BoardZoom.levels { + #expect(BoardZoom.normalize(Double(level)) == level) + } + } + + /// The trap this exists for: `double(forKey:)` answers 0 for a key nobody ever set, and an + /// unfiltered 0 would drive every `BoardMetrics.em` multiple to its 1pt floor. + @Test("Zero is not a level, and never becomes one") + func zeroIsClamped() { + let level = BoardZoom.normalize(0) + #expect(BoardZoom.levels.contains(level)) + #expect(level == BoardZoom.levels.first!) + #expect(BoardZoom.bodyPointSize(system: standardBody, level: level) > 1) + } + + @Test("Garbage lands on a legal rung") + func garbageIsClamped() { + #expect(BoardZoom.levels.contains(BoardZoom.normalize(-4))) + #expect(BoardZoom.levels.contains(BoardZoom.normalize(99))) + #expect(BoardZoom.normalize(99) == BoardZoom.levels.last!) + #expect(BoardZoom.normalize(-4) == BoardZoom.levels.first!) + } + + /// Not a number has no honest nearest rung, so it resolves to the one answer that cannot + /// surprise. + @Test("Non-finite input resolves to Actual Size") + func nonFiniteIsActualSize() { + #expect(BoardZoom.normalize(.nan) == BoardZoom.actualSize) + #expect(BoardZoom.normalize(.infinity) == BoardZoom.actualSize) + #expect(BoardZoom.normalize(-.infinity) == BoardZoom.actualSize) + } + + /// A build that shortened the ladder under a value an older one wrote — snapped, not refused. + @Test("An off-ladder value snaps to its nearest rung") + func offLadderSnaps() { + #expect(BoardZoom.normalize(1.07) == 1.0) + #expect(BoardZoom.normalize(1.12) == 1.15) + #expect(BoardZoom.normalize(1.9) == 2.0) + } +} + +// MARK: - What a level means + +@Suite("Zoom ▸ what a level means") +struct BoardZoomMeaningTests { + + /// The founding guarantee: Actual Size draws pixel-for-pixel what the board drew before zoom + /// existed. + @Test("Actual Size is the system's own ruler, exactly") + func actualSizeIsTheSystemRuler() { + #expect(BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.actualSize) == standardBody) + #expect(BoardZoom.bodyPointSize(system: 18, level: BoardZoom.actualSize) == 18) + } + + /// The other half of that guarantee: a relative text style stays a relative text style, carrying + /// the system's own leading and traits, until a deliberate zoom trades it away. + @Test("Actual Size hands back the relative style itself") + @MainActor + func actualSizeKeepsRelativeStyles() { + #expect(BoardZoom.font(.body, level: BoardZoom.actualSize) == .body) + #expect(BoardZoom.font(.caption, level: BoardZoom.actualSize) == .caption) + #expect(BoardZoom.font(.headline, level: BoardZoom.actualSize) == .headline) + #expect(BoardZoom.font(.caption2, level: BoardZoom.actualSize) == .caption2) + } + + @Test("Off the default rung, the style is scaled rather than kept") + @MainActor + func zoomedStylesAreScaled() { + #expect(BoardZoom.font(.body, level: 2.0) != .body) + #expect(BoardZoom.font(.caption, level: 0.75) != .caption) + } + + @Test("The level scales the ruler monotonically") + func levelScalesTheRuler() { + let sizes = BoardZoom.levels.map { BoardZoom.bodyPointSize(system: standardBody, level: $0) } + #expect(sizes == sizes.sorted()) + #expect(sizes.first! < standardBody) + #expect(sizes.last! > standardBody) + } + + /// Deliberately unrounded: `BoardMetrics.em` already rounds every figure it produces, and + /// rounding here would round twice on two different rulers. + @Test("The ruler is not pre-rounded") + func rulerIsNotRounded() { + #expect(BoardZoom.bodyPointSize(system: 13, level: 1.15) == 13 * 1.15) + } + + /// A level cannot be trusted to be positive until `normalize` has seen it, and no proposal + /// downstream may be zero or negative. + @Test("The ruler is floored at one point") + func rulerIsFloored() { + #expect(BoardZoom.bodyPointSize(system: 13, level: 0) == 1) + #expect(BoardZoom.bodyPointSize(system: 13, level: -2) == 1) + } + + @Test("The percent label is the announcement's value") + func percentLabel() { + #expect(BoardZoom.percentLabel(1.0) == "100%") + #expect(BoardZoom.percentLabel(1.15) == "115%") + #expect(BoardZoom.percentLabel(0.75) == "75%") + #expect(BoardZoom.percentLabel(2.0) == "200%") + #expect(AccessibilityPhrases.zoomLevel(BoardZoom.percentLabel(1.3)) == "Zoom 130%") + } +} + +// MARK: - Every board metric follows the ruler + +/// The point of the whole feature: `BoardMetrics` is already parameterised on the body point size, so +/// moving the level moves every figure the strip draws. Asserted over the table rather than per +/// figure, so a metric added later is covered the day it lands. +@Suite("Zoom ▸ the metrics follow the level") +struct BoardZoomMetricsTests { + + /// Every em-derived figure on the board, by name — `VisualAccommodationsTests`' own inventory + /// shape, which is how a new metric gets caught by this suite without anyone remembering to add + /// it to two lists. + private var figures: [(String, (CGFloat) -> CGFloat)] { [ + ("stripGap", { BoardMetrics.stripGap(bodyPointSize: $0) }), + ("laneCornerRadius", { BoardMetrics.laneCornerRadius(bodyPointSize: $0) }), + ("lanePlatePadding", { BoardMetrics.lanePlatePadding(bodyPointSize: $0) }), + ("laneStackSpacing", { BoardMetrics.laneStackSpacing(bodyPointSize: $0) }), + ("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }), + ("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }), + ("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }), + ("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }), + ("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }), + ("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }), + ("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }), + ("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }), + ("resizeHandleWidth", { BoardMetrics.resizeHandleWidth(bodyPointSize: $0) }), + ("trashHatchSpacing", { BoardMetrics.trashHatchSpacing(bodyPointSize: $0) }), + ] } + + @Test("Zooming in grows every figure; zooming out shrinks every figure") + func figuresFollowTheLevel() { + let resting = BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.actualSize) + let zoomedIn = BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.levels.last!) + let zoomedOut = BoardZoom.bodyPointSize(system: standardBody, level: BoardZoom.levels.first!) + + for (name, figure) in figures { + #expect(figure(zoomedIn) > figure(resting), "\(name) must grow when the board zooms in") + #expect(figure(zoomedOut) < figure(resting), "\(name) must shrink when the board zooms out") + } + } + + @Test("Every figure stays a whole point, and at least one, at every rung") + func figuresStayLegalAtEveryRung() { + for level in BoardZoom.levels { + let size = BoardZoom.bodyPointSize(system: standardBody, level: level) + for (name, figure) in figures { + let value = figure(size) + #expect(value >= 1, "\(name) collapsed at \(BoardZoom.percentLabel(level))") + #expect(value == value.rounded(), "\(name) is not a whole point at \(BoardZoom.percentLabel(level))") + } + } + } + + /// **The no-horizontal-scroll invariant survives every rung** (03-board-ui.md ▸ Layout). Zoom + /// moves the gap, so the lanes narrow; what it must never do is make the strip want more room + /// than the window has. + @Test("The strip still exactly fills its window at every rung") + func stripStillFillsAtEveryRung() { + let stripWidth: CGFloat = 1400 + for level in BoardZoom.levels { + let gap = BoardMetrics.stripGap( + bodyPointSize: BoardZoom.bodyPointSize(system: standardBody, level: level)) + for units in 1...8 { + let standard = LaneLayoutMath.standardWidth( + stripWidth: stripWidth, totalUnits: units, gap: gap) + #expect(standard > 0) + let drawn = standard * CGFloat(units) + gap * CGFloat(units + 1) + #expect(abs(drawn - stripWidth) < 0.001, + "\(units) units at \(BoardZoom.percentLabel(level)) does not fill the strip") + } + } + } + + /// Zoom in and the lanes narrow — the honest consequence of a board that refuses horizontal + /// scroll, and the one thing about this feature a user could be surprised by. Pinned so it stays + /// a decision rather than becoming a bug report. + @Test("Zooming in narrows the lanes rather than widening the strip") + func zoomingInNarrowsLanes() { + let stripWidth: CGFloat = 1400 + let resting = LaneLayoutMath.standardWidth( + stripWidth: stripWidth, totalUnits: 4, + gap: BoardMetrics.stripGap(bodyPointSize: standardBody)) + let zoomed = LaneLayoutMath.standardWidth( + stripWidth: stripWidth, totalUnits: 4, + gap: BoardMetrics.stripGap( + bodyPointSize: BoardZoom.bodyPointSize(system: standardBody, level: 2.0))) + + #expect(zoomed < resting) + // …but only by the gap's growth, which is a few percent — not by the level. + #expect(zoomed > resting * 0.9, "the narrowing is the gap's, not the level's") + } +} + +// MARK: - The persisted level + +@Suite("Zoom ▸ the persisted level") +@MainActor +struct BoardZoomStoreTests { + + @Test("A fresh domain opens at Actual Size") + func freshDomainIsActualSize() { + let (store, _, tearDown) = makeStore() + defer { tearDown() } + + #expect(store.level == BoardZoom.actualSize) + #expect(store.isActualSize) + } + + @Test("A level survives the trip through defaults") + func levelPersists() { + let (store, defaults, tearDown) = makeStore() + defer { tearDown() } + + store.zoomIn() + let level = store.level + #expect(level != BoardZoom.actualSize) + + let reopened = BoardZoomStore(defaults: defaults) + #expect(reopened.level == level) + } + + /// The stored scalar is a plain preference a user can hand-edit; whatever is in there, the store + /// opens on a rung. + @Test("A hand-edited preference still opens on a rung") + func handEditedPreferenceIsNormalised() { + for stored in [0, -1, 1.07, 42] as [Double] { + let (store, _, tearDown) = makeStore(seeding: stored) + defer { tearDown() } + #expect(BoardZoom.levels.contains(store.level), "\(stored) opened off the ladder") + } + } + + @Test("The three moves walk the ladder and hold at its ends") + func movesWalkTheLadder() { + let (store, _, tearDown) = makeStore() + defer { tearDown() } + + for _ in BoardZoom.levels { store.zoomIn() } + #expect(store.level == BoardZoom.levels.last!) + #expect(!store.canZoomIn) + + for _ in BoardZoom.levels { store.zoomOut() } + #expect(store.level == BoardZoom.levels.first!) + #expect(!store.canZoomOut) + + store.actualSize() + #expect(store.level == BoardZoom.actualSize) + #expect(store.isActualSize) + } + + /// The single write path the menu rows and the toolbar buttons share, so the two faces of one + /// command can never drift (`BoardStore.setTrashVisible`'s rule). + @Test("The shared step is the same walk") + func sharedStepIsTheSameWalk() { + let (store, _, tearDown) = makeStore() + defer { tearDown() } + + store.step(.in) + #expect(store.level == BoardZoom.stepIn(from: BoardZoom.actualSize)) + store.step(.out) + #expect(store.level == BoardZoom.actualSize) + store.step(.in) + store.step(.actualSize) + #expect(store.level == BoardZoom.actualSize) + } + + @Test("The context carries the level to the strip") + func contextCarriesTheLevel() { + let (store, _, tearDown) = makeStore() + defer { tearDown() } + + store.zoomIn() + #expect(store.context.level == store.level) + #expect(store.context == BoardZoomContext(level: store.level)) + #expect(BoardZoomContext.actualSize.level == BoardZoom.actualSize) + } +} + +// MARK: - The commands' scope + +@Suite("Zoom ▸ the commands' scope") +@MainActor +struct ZoomCommandScopeTests { + + private func makeBoard() throws -> WriterFixture { + let fixture = try WriterFixture() + try fixture.item("", Item.board) + try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo")) + try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First")) + return fixture + } + + /// The rows are board-scoped: the level is app-wide, but only board windows draw with it, so a + /// ⌘+ over a card window or the welcome screen would be a command that silently missed. + @Test("No board window, no zoom") + func withoutABoardTheRowsAreDead() { + #expect(!ZoomCommands.isEnabled(store: nil, session: DragSession())) + } + + @Test("A board window in front, and nothing in flight, is live") + func withABoardTheRowsAreLive() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + #expect(ZoomCommands.isEnabled(store: store, session: DragSession())) + } + + /// A drag freezes geometry the level feeds — the run's heights, and `RestingLayoutCache`, whose + /// entry key does not include the point size. The guard states that rather than relying on the + /// AppKit drag loop happening to swallow the chord. + @Test("A drag in flight holds all three rows shut") + func aDragHoldsTheRowsShut() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true) + + let cards = DragSession() + cards.beginCards([ItemID(rawValue: Ident.card1)], folders: [folder], heights: [44], + container: .board, source: store) + #expect(!ZoomCommands.isEnabled(store: store, session: cards)) + + let lanes = DragSession() + lanes.beginLanes([ItemID(rawValue: Ident.lane1)], folders: [folder], units: [1], source: store) + #expect(!ZoomCommands.isEnabled(store: store, session: lanes)) + } + + /// Zooming is a view change, not a mutation — `ShowTrashCommand`'s rule. So the rows are not + /// gated on `acceptsBoardMutations`, which an open inline editor is enough to falsify: a user + /// naming a new card can still make the board bigger to read it. + @Test("A board that refuses mutations still zooms") + func mutationRefusalDoesNotCloseTheRows() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + store.transient.beginPlaceholder(inLane: ItemID(rawValue: Ident.lane1)) + #expect(!store.acceptsBoardMutations, "the fixture must actually be refusing mutations") + #expect(ZoomCommands.isEnabled(store: store, session: DragSession())) + } +} diff --git a/KanbanTests/DragAutoScrollMathTests.swift b/KanbanTests/DragAutoScrollMathTests.swift index ef2dae1..9ee7ec4 100644 --- a/KanbanTests/DragAutoScrollMathTests.swift +++ b/KanbanTests/DragAutoScrollMathTests.swift @@ -121,27 +121,64 @@ struct DragAutoScrollMathTests { // MARK: Engagement reach + /// The standard system body size — the ruler every figure below was tuned against. + private static let standardBody: CGFloat = 13 + @Test("Engagement reaches over the header but barely sideways") func engagementReach() { let viewport = CGSize(width: 240, height: 400) - let reach = DragAutoScrollMath.engagementRect(viewport: viewport) + let size = Self.standardBody + let above = DragAutoScrollMath.reachAbove(bodyPointSize: size) + let below = DragAutoScrollMath.reachBelow(bodyPointSize: size) + let side = DragAutoScrollMath.reachSide(bodyPointSize: size) + let reach = DragAutoScrollMath.engagementRect(viewport: viewport, bodyPointSize: size) #expect(reach.contains(CGPoint(x: 120, y: 200)), "inside the visible area, always") // Above it (the lane header) and below it (the strip's padding). - #expect(reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove + 1))) - #expect(reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow - 1))) - #expect(!reach.contains(CGPoint(x: 120, y: -DragAutoScrollMath.reachAbove - 1))) - #expect(!reach.contains(CGPoint(x: 120, y: viewport.height + DragAutoScrollMath.reachBelow + 1))) + #expect(reach.contains(CGPoint(x: 120, y: -above + 1))) + #expect(reach.contains(CGPoint(x: 120, y: viewport.height + below - 1))) + #expect(!reach.contains(CGPoint(x: 120, y: -above - 1))) + #expect(!reach.contains(CGPoint(x: 120, y: viewport.height + below + 1))) // Sideways: only a sliver, so the neighbouring lane never engages. - #expect(reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide + 1, y: 200))) - #expect(!reach.contains(CGPoint(x: -DragAutoScrollMath.reachSide - 1, y: 200))) - #expect(!reach.contains(CGPoint(x: viewport.width + DragAutoScrollMath.reachSide + 1, y: 200))) + #expect(reach.contains(CGPoint(x: -side + 1, y: 200))) + #expect(!reach.contains(CGPoint(x: -side - 1, y: 200))) + #expect(!reach.contains(CGPoint(x: viewport.width + side + 1, y: 200))) // The sideways reach must stay under half the distance between two lanes' scroll areas, or // two lanes would scroll at once. - #expect(DragAutoScrollMath.reachSide < 28 / 2) + #expect(side < 28 / 2) + } + + @Test("The standard body size draws the reaches it always drew") + func reachesAtStandardBodySize() { + let size = Self.standardBody + #expect(DragAutoScrollMath.reachAbove(bodyPointSize: size) == 48) + #expect(DragAutoScrollMath.reachBelow(bodyPointSize: size) == 24) + #expect(DragAutoScrollMath.reachSide(bodyPointSize: size) == 12) + } + + /// The reaches are distances to the lane's own furniture — a header, a padding, a gap — and all + /// three of those grow with the board's zoom (03-board-ui.md ▸ Layout — zoom). A reach fixed in + /// points would stop covering the header it is specified against. + @Test("Every reach grows with the board's ruler") + func reachesFollowTheRuler() { + let standard = Self.standardBody + let zoomed = BoardZoom.bodyPointSize(system: standard, level: 2.0) + + #expect(DragAutoScrollMath.reachAbove(bodyPointSize: zoomed) + > DragAutoScrollMath.reachAbove(bodyPointSize: standard)) + #expect(DragAutoScrollMath.reachBelow(bodyPointSize: zoomed) + > DragAutoScrollMath.reachBelow(bodyPointSize: standard)) + #expect(DragAutoScrollMath.reachSide(bodyPointSize: zoomed) + > DragAutoScrollMath.reachSide(bodyPointSize: standard)) + + // And the sideways rule holds on the zoomed ruler too: half the distance between two lanes' + // scroll areas is the gap plus a plate padding on each side. + let gap = BoardMetrics.stripGap(bodyPointSize: zoomed) + let padding = BoardMetrics.lanePlatePadding(bodyPointSize: zoomed) + #expect(DragAutoScrollMath.reachSide(bodyPointSize: zoomed) <= (gap + 2 * padding) / 2) } // MARK: Stepping the offset diff --git a/KanbanTests/DropSlotMathTests.swift b/KanbanTests/DropSlotMathTests.swift index da7ce69..b29b690 100644 --- a/KanbanTests/DropSlotMathTests.swift +++ b/KanbanTests/DropSlotMathTests.swift @@ -661,7 +661,7 @@ struct CardSlotTests { struct FileDropZoneTests { private let placement = MasonryPlacement(columnCount: 2, columnWidth: 100, spacing: 8) private let heights: [CGFloat] = [40, 60, 30, 20, 50] - private let nominal = LaneDropRegistry.nominalCardHeight + private let nominal = LaneDropRegistry().nominalCardHeight private func landing( _ x: CGFloat, _ y: CGFloat, headerBottom: CGFloat? = nil, current: Int? = nil, diff --git a/KanbanTests/HistoryProviderTests.swift b/KanbanTests/HistoryProviderTests.swift index 0b7cbf7..d1a6cb6 100644 --- a/KanbanTests/HistoryProviderTests.swift +++ b/KanbanTests/HistoryProviderTests.swift @@ -837,7 +837,12 @@ struct UndoCommandSurfaceTests { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation()) + let controller = BoardToolbar.controller( + store: store, + search: BoardSearchPresentation(), + zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!), + session: DragSession() + ) let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider) let (window, hosted) = hostedWindow(manager) @@ -888,7 +893,14 @@ struct UndoCommandSurfaceTests { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let toolbar = BoardToolbar.controller(store: store, search: BoardSearchPresentation()) + let zoomDomain = "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)" + defer { UserDefaults.standard.removePersistentDomain(forName: zoomDomain) } + let toolbar = BoardToolbar.controller( + store: store, + search: BoardSearchPresentation(), + zoom: BoardZoomStore(defaults: UserDefaults(suiteName: zoomDomain)!), + session: DragSession() + ) let provider = FakeHistoryProvider() let manager = BoardUndoManager(history: provider, isReadOnly: { lock.isOn }) let (window, hosted) = hostedWindow(manager) @@ -928,7 +940,12 @@ struct UndoCommandSurfaceTests { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation()) + let specs = BoardToolbar.specs( + store: store, + search: BoardSearchPresentation(), + zoom: BoardZoomStore(defaults: UserDefaults(suiteName: "dev.rzen.indie.Kanban.history-tests.\(UUID().uuidString)")!), + session: DragSession() + ) // Every other item mirrors its menu row's predicate; these two mirror the *mechanism*. A // spec-level `isEnabled` here would be a second answer able to disagree with the responder diff --git a/KanbanTests/RestingLayoutCacheTests.swift b/KanbanTests/RestingLayoutCacheTests.swift index 24b5476..b9c4cec 100644 --- a/KanbanTests/RestingLayoutCacheTests.swift +++ b/KanbanTests/RestingLayoutCacheTests.swift @@ -96,7 +96,7 @@ struct RestingLayoutCacheTests { let layout = cache.layout(inLane: lane1, of: store, registry: registry, hidden: []) - let nominal = LaneDropRegistry.nominalCardHeight + let nominal = registry.nominalCardHeight #expect(layout?.heights == [40, nominal, nominal]) } @@ -194,7 +194,7 @@ struct RestingLayoutRegroundingTests { // uncached path left it. let reground = cache.layout(inLane: lane1, of: store, registry: registry, hidden: []) #expect(reground?.cardIDs == [card1, foreignCard, card2, card3]) - #expect(reground?.heights == [40, LaneDropRegistry.nominalCardHeight, 60, 80]) + #expect(reground?.heights == [40, registry.nominalCardHeight, 60, 80]) #expect(cache.builds == 2) #expect(cache.reuses == 0) } @@ -288,7 +288,7 @@ struct RestingLayoutMeasurementTests { registry.removeHeight(card3) #expect(cache.layout(inLane: lane1, of: store, registry: registry, hidden: [])?.heights - == [40, 60, LaneDropRegistry.nominalCardHeight]) + == [40, 60, registry.nominalCardHeight]) #expect(cache.builds == 2) // A second removal of the same card is not a change, and must not cost a rebuild. @@ -312,7 +312,7 @@ struct RestingLayoutMeasurementTests { #expect(cache.layout(inLane: lane1, of: store, registry: settled, hidden: [])?.heights == [40, 60, 80]) - let nominal = LaneDropRegistry.nominalCardHeight + let nominal = opening.nominalCardHeight #expect(cache.layout(inLane: lane1, of: store, registry: opening, hidden: [])?.heights == [nominal, nominal, nominal]) #expect(cache.builds == 2) diff --git a/KanbanTests/ToolbarTests.swift b/KanbanTests/ToolbarTests.swift index 9c18061..550b8e6 100644 --- a/KanbanTests/ToolbarTests.swift +++ b/KanbanTests/ToolbarTests.swift @@ -31,6 +31,28 @@ private extension Array where Element == ToolbarItemSpec { } } +/// A zoom store on a scratch defaults domain — the level is app-wide and persisted, so a suite that +/// used `.standard` would zoom the developer's own boards (`StyleRecents`' injection, for its reason). +@MainActor +private func makeZoom(level: CGFloat = BoardZoom.actualSize) -> BoardZoomStore { + let name = "dev.rzen.indie.Kanban.toolbar-tests.\(UUID().uuidString)" + let store = BoardZoomStore(defaults: UserDefaults(suiteName: name)!) + store.setLevel(level) + return store +} + +/// The board catalog, with the two collaborators every test here supplies the same way: a fresh zoom +/// store and a drag session with nothing in flight. +@MainActor +private func boardSpecs( + store: BoardStore, + search: BoardSearchPresentation = BoardSearchPresentation(), + zoom: BoardZoomStore? = nil, + session: DragSession = DragSession() +) -> [ToolbarItemSpec] { + BoardToolbar.specs(store: store, search: search, zoom: zoom ?? makeZoom(), session: session) +} + // MARK: - The vocabulary /// **Toolbar item labels are menu titles minus a trailing ellipsis** (03-board-ui.md ▸ Toolbar) — @@ -86,18 +108,21 @@ struct BoardToolbarTests { #expect(BoardToolbar.defaultItems.filter { $0 != .flexibleSpace } == [.boardSearch]) } - @Test("The catalog is 03's five commands plus the field — and the board popover is not in it") + @Test("The catalog is 03's seven commands plus the field — and the board popover is not in it") func catalogIsTheDesignsInventory() throws { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation()) + let specs = boardSpecs(store: store) - // "Catalog (available via Customize): New Card, New Lane, Undo, Redo …, Show Trash" — plus - // the search field, which is a catalog item too (a user who removes it can put it back). + // "Catalog (available via Customize): New Card, New Lane, Zoom In, Zoom Out …, Undo, Redo …, + // Show Trash" — plus the search field, which is a catalog item too (a user who removes it can + // put it back). #expect(specs.map(\.identifier) == [ .boardNewCard, .boardNewLane, + .boardZoomIn, + .boardZoomOut, .boardUndo, .boardRedo, .boardShowTrash, @@ -106,7 +131,86 @@ struct BoardToolbarTests { // "The board popover deliberately has no toolbar item — the window-title widget is its // committed home, and a second entry would muddy it." Absence is a settlement, so it is // pinned by the exact-inventory assertion above and stated again here. - #expect(specs.count == 6) + #expect(specs.count == 8) + } + + /// The zoom pair is catalog-only — "the titlebar's default stays the search field alone" + /// (03-board-ui.md ▸ Toolbar). Stated separately from the default-set test because this is the + /// claim a future item is most likely to break by helpfully adding itself. + @Test("Zoom In and Zoom Out are available but never default") + func zoomIsCatalogOnly() { + #expect(!BoardToolbar.defaultItems.contains(.boardZoomIn)) + #expect(!BoardToolbar.defaultItems.contains(.boardZoomOut)) + } + + /// There is deliberately no Actual Size item: it is a menu row only. Two buttons are the whole + /// of what a toolbar can usefully offer for a ladder with no readout — a third that resets is + /// titlebar clutter for a chord. + @Test("Actual Size has no toolbar item") + func actualSizeIsMenuOnly() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + #expect(!boardSpecs(store: store).contains { $0.label == "Actual Size" }) + } + + /// Each button mirrors its menu row's ladder end, which is the same predicate `ZoomCommands` + /// disables on — one answer, two faces (03-board-ui.md ▸ Toolbar). + @Test("The zoom buttons disable at their own end of the ladder") + func zoomButtonsMirrorTheLadderEnds() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // Every collaborator a spec's `isEnabled` interrogates lives in a local for the whole + // test, exactly as `store` already does: the closures capture them weakly — production + // hands them app-lived objects — so a temporary reads as uniformly disabled. + let session = DragSession() + let topZoom = makeZoom(level: BoardZoom.levels.last!) + let top = boardSpecs(store: store, zoom: topZoom, session: session) + #expect(try !#require(top.spec(.boardZoomIn)).isEnabled) + #expect(try #require(top.spec(.boardZoomOut)).isEnabled) + + let bottomZoom = makeZoom(level: BoardZoom.levels.first!) + let bottom = boardSpecs(store: store, zoom: bottomZoom, session: session) + #expect(try #require(bottom.spec(.boardZoomIn)).isEnabled) + #expect(try !#require(bottom.spec(.boardZoomOut)).isEnabled) + + let middleZoom = makeZoom() + let middle = boardSpecs(store: store, zoom: middleZoom, session: session) + #expect(try #require(middle.spec(.boardZoomIn)).isEnabled) + #expect(try #require(middle.spec(.boardZoomOut)).isEnabled) + } + + /// Both are push buttons, and both go dead while a drag is in flight — the menu rows' guard, + /// reached through the very same predicate (`ZoomCommands.isEnabled`). + @Test("The zoom buttons are push buttons, and hold shut mid-drag") + func zoomButtonsHoldShutMidDrag() throws { + let fixture = try makeBoard() + defer { fixture.tearDown() } + let store = try BoardStore(rootURL: fixture.root) + + // Held in locals for the reason `zoomButtonsMirrorTheLadderEnds` spells out — and the + // dragging half asks with the same live zoom, so its disabled answer is the drag guard's + // rather than a dead weak reference's. + let zoom = makeZoom() + let restingSession = DragSession() + let resting = boardSpecs(store: store, zoom: zoom, session: restingSession) + for identifier in [NSToolbarItem.Identifier.boardZoomIn, .boardZoomOut] { + let spec = try #require(resting.spec(identifier)) + #expect(spec.isOn == nil, "\(spec.label) is a push button, not a toggle") + #expect(spec.isEnabled) + } + + let session = DragSession() + let folder = store.rootURL.appendingPathComponent(Ident.card1, isDirectory: true) + session.beginCards([ItemID(rawValue: Ident.card1)], folders: [folder], heights: [44], + container: .board, source: store) + let dragging = boardSpecs(store: store, zoom: zoom, session: session) + for identifier in [NSToolbarItem.Identifier.boardZoomIn, .boardZoomOut] { + #expect(try !#require(dragging.spec(identifier)).isEnabled) + } } @Test("Every label is its menu row's title, and Undo/Redo keep static ones") @@ -114,9 +218,11 @@ struct BoardToolbarTests { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation()) + let specs = boardSpecs(store: store) - #expect(specs.map(\.label) == ["New Card", "New Lane", "Undo", "Redo", "Show Trash", "Search"]) + #expect(specs.map(\.label) == [ + "New Card", "New Lane", "Zoom In", "Zoom Out", "Undo", "Redo", "Show Trash", "Search", + ]) // The one exception 03 names: "the Undo/Redo toolbar items keep static labels — // NSUndoManager rewrites their menu titles dynamically ('Undo Move Card…'), which a toolbar // label doesn't track". They are also the two items with no action of their own: nil target, @@ -137,7 +243,7 @@ struct BoardToolbarTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - for spec in BoardToolbar.specs(store: store, search: BoardSearchPresentation()) { + for spec in boardSpecs(store: store) { guard let symbol = spec.symbol else { continue } #expect(ItemSymbol.exists(symbol), "\(spec.label) draws a symbol this OS does not have") } @@ -151,13 +257,13 @@ struct BoardToolbarTests { defer { empty.tearDown() } let store = try BoardStore(rootURL: populated.root) - let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation()) + let specs = boardSpecs(store: store) let newCard = try #require(specs.spec(.boardNewCard)) #expect(newCard.isEnabled) #expect(newCard.isOn == nil, "New Card is a push button, not a toggle") let emptyStore = try BoardStore(rootURL: empty.root) - let emptySpecs = BoardToolbar.specs(store: emptyStore, search: BoardSearchPresentation()) + let emptySpecs = boardSpecs(store: emptyStore) let disabled = try #require(emptySpecs.spec(.boardNewCard)) #expect(!disabled.isEnabled, "the zero-lane board disables the row, so it disables the item") #expect(emptyStore.newCardTarget == nil, "one predicate, read by both") @@ -169,7 +275,7 @@ struct BoardToolbarTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let search = BoardSearchPresentation() - let controller = BoardToolbar.controller(store: store, search: search) + let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession()) // 03's three customization sentences: "right-click ▸ Customize Toolbar…, drag to rearrange, // system overflow and icon/text display options". @@ -181,7 +287,7 @@ struct BoardToolbarTests { #expect(allowed.contains(.flexibleSpace) && allowed.contains(.space), "the palette's spacers") #expect(controller.toolbarDefaultItemIdentifiers(controller.toolbar) == BoardToolbar.defaultItems) - for spec in BoardToolbar.specs(store: store, search: BoardSearchPresentation()) { + for spec in boardSpecs(store: store) { let item = try #require(controller.toolbar( controller.toolbar, itemForItemIdentifier: spec.identifier, @@ -198,7 +304,7 @@ struct BoardToolbarTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let search = BoardSearchPresentation() - let controller = BoardToolbar.controller(store: store, search: search) + let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession()) #expect(search.focusField == nil, "nothing to focus until the item exists") @@ -233,7 +339,7 @@ struct BoardToolbarTests { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation()) + let controller = BoardToolbar.controller(store: store, search: BoardSearchPresentation(), zoom: makeZoom(), session: DragSession()) let item = try #require(controller.toolbar( controller.toolbar, @@ -267,7 +373,7 @@ struct BoardToolbarTests { defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) let search = BoardSearchPresentation() - let controller = BoardToolbar.controller(store: store, search: search) + let controller = BoardToolbar.controller(store: store, search: search, zoom: makeZoom(), session: DragSession()) _ = controller.toolbar( controller.toolbar, @@ -290,7 +396,7 @@ struct BoardToolbarTests { let fixture = try makeBoard() defer { fixture.tearDown() } let store = try BoardStore(rootURL: fixture.root) - let specs = BoardToolbar.specs(store: store, search: BoardSearchPresentation()) + let specs = boardSpecs(store: store) let showTrash = try #require(specs.spec(.boardShowTrash)) #expect(showTrash.isOn == false, "hidden by default, like the menu row's checkmark") diff --git a/README.md b/README.md index 131b006..338acf2 100644 --- a/README.md +++ b/README.md @@ -49,13 +49,13 @@ Lanework is in early development. This list tracks what has actually shipped and - **The board popover** — a quiet chevron beside the window title (File ▸ Board Info, ⌘I, which toggles it) opens the board's one configuration surface. Renaming edits the board's frontmatter `title` and nothing else — the folder is never renamed, so the app's display name and the Finder document name are free to diverge — and clearing the field removes the key entirely, dropping the window title back to the folder name rather than to "Untitled"; the edit commits on Return and on click-away, Escape abandons it, and an unchanged title writes nothing at all. Below it sits the same style editor every other anchor uses, aimed permanently at the board; on an ordinary free-tier board the popover ends there, and only a board carrying a `.git` gets a closing note — "This board has a git history. Lanework Pro works with it." Under a Lanework Pro subscription that slot becomes the board's git section instead, and it follows the board's mode: a board with no repository offers **Add Git**, a board that lives inside somebody else's repository gets a short honest explanation rather than a hidden or greyed-out action, and a git board carries the branch surface: the current branch with switching and create-and-switch beside it, the plain-language explanation when an outside-the-app merge or rebase has the git surface paused, and the commit-identity name and email fields that write the repository's own `.git/config`. The read-only lock disables the surface without closing it. -- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode. +- **Customizable toolbars** — both windows carry a real macOS toolbar: right-click ▸ Customize Toolbar…, drag to rearrange, the system overflow, and the Icon and Text / Icon Only / Text Only display options, with your arrangement remembered across launches. They are pure enhancement — every item is a menu command with a shortcut, so removing all of them costs you nothing but a click. The board ships with the search field alone, trailing, and offers New Card, New Lane, Zoom In, Zoom Out, Undo, Redo and Show Trash in the palette (Undo and Redo validate exactly as the Edit menu's rows do, and keep static labels because the menu's titles rewrite themselves); the board popover deliberately has no item, since the window-title chevron is its home. Take the search field out and ⌘F still summons search — the field appears in a strip just under the title bar and stays until the search clears, keeping the keyboard while you type. The card window ships Edit Body · Raw Source · Add Attachment, the first two as toggles showing their on-state, with Edit Body disabling while raw source is up and Add Attachment live in every mode. - **Undo** — ⌘Z and ⇧⌘Z are native macOS undo, per board: one stack owned by the board's session and shared by every window over it, so a card window's ⌘Z crosses the same step the board window's does, and another board's never does. Every app-mediated mutation registers an inverse at the write boundary — create, move, reorder, rename, restyle, resize, delete, and an Edit session's whole run of saves — with a restore registering as the ordinary move it is — one gesture to one step, named in the app's own vocabulary so the Edit menu reads "Undo Move 3 Cards" and the toolbar's twins light up and dim with it. Undoing is a real write, never an in-memory revert: it goes through the same atomic writer, echoes back through the watcher, and refreshes every window. Because the app is not the only writer, each step re-checks its target the moment you press ⌘Z — field by field, against what its own write left — and a step the disk has moved past is **skipped rather than applied**, with a quiet row saying which item changed outside Lanework, while ⌘Z falls through to the next step; a step that merely failed to write (a full disk, an unplugged volume) stays put to be retried. Permanent deletion and the duplicate-id repair are deliberately outside it — the confirmation is the safety — attachment add and remove register nothing in v1, and foreign edits never join the stack. The read-only lock disables Undo and Redo with every other mutating command and gives them back, stack intact, when it clears. The stack lives with the session and dies at close, standard macOS behaviour. The free tier runs this stack on every board; under Lanework Pro a git board binds git behind the same seam instead, without changing a keystroke — see "Undo as forward commits" below. - **The agent guide** — every board root carries a `CLAUDE.md` the app writes and keeps current: a condensed, agent-facing rendition of the schema — the folder layout, ordering arithmetic, creating and moving cards, the `.trash/` convention, `attachments/`, `modified-by` self-stamping, the colour and icon palettes, and the git etiquette — so any file-capable agent dropped into the folder already knows how to work the board. It is app-owned and version-gated by a marker in its first line: rewritten when missing or older, left byte-for-byte alone when current or newer, and re-checked on every reload, so a guide deleted or rolled back from outside heals by itself. A `CLAUDE.md` that isn't the app's is never clobbered — it moves to `CLAUDE.user.md` (the user's own extension point, which the app otherwise never touches), and if that name is taken the app simply doesn't write a guide. A symlink or folder wearing the name is moved aside — Finder-style, never destroyed, with a quiet row naming where it went — because the app owns that name; and a board on a read-only volume is skipped in silence: the guide is a courtesy and never an interruption. -- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent. Which is which is decided **per file by the write-provenance ledger**, never by which kind of reload delivered it: a reconciling sweep on wake or reactivation announces whatever changed in the blind window (the app never vouches for changes it didn't witness), and a foreign edit that lands on a file the app had just written is still announced. A card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. **A coloured board computes its own text colour.** The board background is the one surface the app lets a colour sit behind text, so the ink is chosen rather than assumed: WCAG relative luminance against the ≥ 4.5:1 threshold, with an `#RRGGBBAA` value composited over the window background of the appearance you are actually in — so lane and trash headers take light or dark glyphs on their own and re-decide the moment you switch to Dark Mode. One path serves both halves of the styling vocabulary: the twelve palette wells are pinned by a test that checks the ink the app *picks* for each of them in both appearances (a dark palette board is now readable in Light Mode, which it was not), and a hand-written hex — which stays fully honoured from disk — gets the identical computation as it renders. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled. +- **Accessibility** — the board is a real VoiceOver surface, not a grid of unlabelled rectangles: lanes are containers read as "⟨title⟩, lane, N cards" (the count is the filter's, like the visible badge), each card is one flattened element carrying its title, its attachment count and its cut-pending state, and traversal follows card `order` rather than masonry column position. VO-Space toggles selection through the same funnel a ⌘-click uses, context-menu rows double as custom actions, lane titles are headings for the rotor, and the trash column pins last. A **live board announces itself**: a foreign edit lands as one polite, non-interrupting digest per reload — "Board changed: 2 cards edited, 1 card added" — while the app's own writes stay silent. Which is which is decided **per file by the write-provenance ledger**, never by which kind of reload delivered it: a reconciling sweep on wake or reactivation announces whatever changed in the blind window (the app never vouches for changes it didn't witness), and a foreign edit that lands on a file the app had just written is still announced. A card that disappears under the cursor is named rather than merely lost ("Card 'Fix login' was deleted externally"), with focus recovering to its lane; when the lane went too, the announcement names the *lane* and its count and focus walks up then sideways to whatever now holds its position. Bracketed operations say one thing at completion and never their internal churn, and the banner strip is an announced element in its own right — the read-only lock and reload breakage speak when they appear and when they clear. **Every size in the app is relative**: there is not one hard-coded point size left — the card face, the lane header, the masonry, the style editor's wells and every window's floor derive from the system body font, so the whole board grows with the system text size while the no-horizontal-scroll rule holds (the lanes compress, the strip never scrolls) and titles keep truncating gracefully. **View ▸ Zoom In / Zoom Out / Actual Size (⌘+ / ⌘− / ⌘0) is that scaling's control**, since macOS ships no text-size setting of its own: eight rungs from 75% to 200%, moving the type, the card chrome and the lane chrome together off one ruler, remembered across launches and shared by every open board window. It is a zoom, not a magnification — the lanes still divide the window's width, because every lane being on screen is the rule the board is built around; what changes is how large the cards are and how many of them fit. Actual Size draws exactly what the board drew before zoom existed, down to the pixel, and each rung announces itself to VoiceOver. The system's visual accommodations are wired throughout: **Increase Contrast** thickens every border and selection ring and gives card and lane plates an outline they don't otherwise have, **Reduce Transparency** turns the transient search bar's glass and the trash column's washes solid, and **Reduce Motion** has a variant for every animated surface in the app — movement goes instant, appear/disappear goes crossfade, uniformly, the live-reload seam included. **A coloured board computes its own text colour.** The board background is the one surface the app lets a colour sit behind text, so the ink is chosen rather than assumed: WCAG relative luminance against the ≥ 4.5:1 threshold, with an `#RRGGBBAA` value composited over the window background of the appearance you are actually in — so lane and trash headers take light or dark glyphs on their own and re-decide the moment you switch to Dark Mode. One path serves both halves of the styling vocabulary: the twelve palette wells are pinned by a test that checks the ink the app *picks* for each of them in both appearances (a dark palette board is now readable in Light Mode, which it was not), and a hand-written hex — which stays fully honoured from disk — gets the identical computation as it renders. Nothing is ever said by colour alone (selection is a ring plus a trait, a cut card is dimmed plus "cut, pending paste", the trash header is hatched plus labelled, a mixed batch reads "mixed"), and under **Full Keyboard Access** the board is a single visible tab stop with the arrow grammar inside it while every control around it — lane buttons, popovers, the style grids, welcome rows, template tiles — is Tab-reachable, arrow-navigable and labelled. - **App identity — icon, versioning, About** — the app carries its three-lane glyph icon and a real About window: icon, copyright, version and build stamped at build time from git (`CFBundleVersion` = commit count, plus `BuildDate` and `BuildHash` in the Info.plist — never a hardcoded string), the version line opening the bundled end-user changelog, and the ISC license one link away. The box carries the one quiet line naming Lanework Pro — one of the three places the app names it at all, per the quiet-signposts rule (DESIGN/12). diff --git a/RENDER-INSTRUMENTATION.md b/RENDER-INSTRUMENTATION.md index 367ddf7..f9514f4 100644 --- a/RENDER-INSTRUMENTATION.md +++ b/RENDER-INSTRUMENTATION.md @@ -54,6 +54,21 @@ Observation tracks whole **properties**. Reading `.background` off `store.snapsh **Selection is O(board) in card bodies.** Selecting one card re-runs all 180 faces, because `CardFaceView.body` reads `store.selection` through `isSelected`. This is the Observation half the gates explicitly do not cover, and narrowing it would mean each face taking its own selected-ness as a compared parameter — a design change, not a gate. +### The zoom pair (2026-08-03) + +Board zoom (03-board-ui.md ▸ Layout — zoom) added two steps to the suite, and they are the only ones here that assert a body count is **non-zero** — because for zoom, a suppressed render is the bug. + +| Step | strip | containers | cards | +| --- | --- | --- | --- | +| zoom in one rung | 1 | **12** of 12 | **540** of 540 | +| Actual Size when already there | **0** | **0** | **0** | + +A whole-board repaint is the *right* answer for the first row, not a budget overrun: every figure the strip draws is a multiple of the level, so every lane's chrome and every card's geometry genuinely changed. What is asserted is only that the numbers are non-zero. + +**The gate must not swallow a level change, and it doesn't — because the level travels in the environment.** `CardFaceView.==` compares the card, its role, its store, the marquee and the drop context; a ⌘+ leaves all five identical. The faces repaint only because `@Environment` values are outside the comparison by design ("SwiftUI invalidates on those itself"), which is exactly why `BoardZoomContext` is an environment value rather than a read off `AppModel`. A zero in that row would be the board zooming its lane chrome while every card face stayed at 13pt — `zoomRepaintsTheCardFaces` is the tripwire. + +**A board nobody zoomed pays nothing.** `@Observable` notifies on every set, equal or not, so `BoardZoomStore.setLevel` refuses an unchanged level outright rather than re-running the strip to draw what it was already drawing. `actualSizeIsTheUntouchedBoard` pins that guard, and pins that the resting ruler is the system's own body size. + ## The signposts | Signpost | Span | Emitted from |