The lane header no longer draws a new-card button. Every other creation path is untouched: File > New Card (Cmd-N), Return on a selected lane, and double-click on lane empty space. - LaneView: drop the button and its overlay slot, leaving the collapse chevron as the header's one piece of trailing chrome. - NewCardTarget: drop the button's click-names-target carve-out over the Cmd-N target rule (Return on a selected lane is now the only direct-target path left). - AccessibilityPhrases: drop the button's spoken label. - BoardMetrics: drop its width reserve; laneHeaderTrailingReserve now reduces to the collapse chevron's own reserve. - DESIGN/03, 04, 10, 11: update the lane title-bar inventory, the Cmd-N override clause and search-clearing mechanism list, the accessibility tree-shape sentence, and the pointer-grammar row that described the button. - Tests: drop the two pinned accessibility-label tests and the newCardButtonReserve assertions; retarget the truncation-headroom test at the now-solo laneHeaderTrailingReserve; fix stale doc-quote comments and the manual accessibility-verification checklist. Full KanbanTests suite: 2807 tests, 482 suites, all passing. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
248 lines
13 KiB
Swift
248 lines
13 KiB
Swift
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.laneCollapseButtonReserve` is 1.4 em rather than 18pt.
|
||
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
|
||
}
|
||
}
|
||
}
|