The board learns to zoom — eight rungs on one ruler, and Actual Size is the untouched board

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
This commit is contained in:
2026-08-07 11:22:02 -04:00
parent d5ad21c3da
commit cfee4a4b41
29 changed files with 1491 additions and 101 deletions
+99 -2
View File
@@ -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")
}
}