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
+20 -4
View File
@@ -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)
}
}