Files
lanework/Kanban/UI/Board/BoardMetrics.swift
T
rzen eb950ee881 Remove the lane header's "+" new-card control
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
2026-08-08 23:28:05 -04:00

291 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import CoreGraphics
/// The board strip's geometry — **derived from font metrics, never written down in points**
/// (10-accessibility.md ▸ Text scaling & visual accommodations: "relative text styles everywhere, no
/// fixed point sizes. Card face, lane header, and masonry metrics derive from font metrics, so
/// layout survives the largest system text sizes").
///
/// This is `CardWindowMetrics`' twin on the board side, and deliberately the same shape: a pure
/// arithmetic surface parameterised on the body font's point size, plus one impure read of what that
/// point size currently is. Everything the strip draws that is not a piece of text — the inter-lane
/// gap, a lane's plate inset, a card's corner radius and stripe, the masonry's card spacing, the
/// trash column's header — is a multiple of that size, so the board grows with the system text size
/// instead of squeezing text into chrome sized for 13pt.
///
/// ### Why a point size and not a `Font`
///
/// Because layout takes numbers. SwiftUI's relative text styles handle the *text* (and every `Text`
/// on the board wears one — `.body`, `.headline`, `.caption`); what they cannot do is tell an
/// `.padding()` how much room the text will need. So the two halves of "full relative scaling" are
/// split by mechanism: type styles for glyphs, this surface for everything between them, both keyed
/// to the same system font.
///
/// ### The em, and what the multiples mean
///
/// Every figure below is a multiple of the body point size — an *em*, roughly — chosen so that at
/// the standard 13pt system body font it reproduces the numbers the board already drew. That is
/// deliberate: this milestone is meant to make the board *scale*, not to redesign it, so the default
/// text size must render pixel-for-pixel what it rendered before. The multiples are what carries the
/// design to 18pt, 24pt and beyond.
///
/// Results are rounded to whole points (SwiftUI will happily lay out on halves, but a hairline
/// border on a half-point boundary blurs) and floored at 1 for anything that is a width or a height,
/// so no proposal is ever zero or negative.
///
/// ### The no-horizontal-scroll invariant is untouched
///
/// 03-board-ui.md § Layout — full visibility divides the window's width across the lanes' width
/// units, and `LaneLayoutMath` takes the gap as a parameter. A larger text size therefore means a
/// larger gap and *narrower* lanes, never a wider strip: "the degenerate case is accepted, not
/// floored … titles and cards truncate gracefully". The truncation rules are the views' own
/// (`lineLimit(1)` + `.tail` on a lane title, `lineLimit(4)` on a card title), and they hold at every
/// scale because they are stated in lines rather than in points.
enum BoardMetrics {
// MARK: - The unit
/// `multiple` ems of the body font, rounded to a whole point and floored at one.
///
/// Floored rather than clamped to zero because every caller is a length: a spacing of zero is a
/// legitimate design choice, but none of the figures below is one, and a rounding that produced
/// zero would silently collapse a stripe or a band rather than shrink it.
static func em(_ multiple: CGFloat, bodyPointSize: CGFloat) -> CGFloat {
max(1, (bodyPointSize * multiple).rounded())
}
// MARK: - The strip
/// The inter-lane gap **and** the strip's outer margin — one number, because the standard-width
/// formula counts `units + 1` of them (`LaneLayoutMath.standardWidth`).
///
/// 0.9 em: 12pt at the standard 13pt body, which is what the strip has always drawn.
static func stripGap(bodyPointSize: CGFloat) -> CGFloat {
em(0.9, bodyPointSize: bodyPointSize)
}
// MARK: - The lane
/// The lane plate's corner radius — shared by the selection treatment and the accent band, whose
/// top corners round to exactly this so the band reads as the lane's own edge.
static func laneCornerRadius(bodyPointSize: CGFloat) -> CGFloat {
em(0.75, bodyPointSize: bodyPointSize)
}
/// The lane plate's inset around its header and its masonry.
static func lanePlatePadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.45, bodyPointSize: bodyPointSize)
}
/// Between the lane's header and its card stack.
static func laneStackSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.6, bodyPointSize: bodyPointSize)
}
/// Between the header's glyph, its title and its count badge.
static func laneHeaderSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.45, bodyPointSize: bodyPointSize)
}
/// The header row's own horizontal inset inside the plate.
static func laneHeaderInset(bodyPointSize: CGFloat) -> CGFloat {
em(0.3, bodyPointSize: bodyPointSize)
}
/// **A collapsed lane's slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — the one lane
/// width in the app that is *not* the window's division: a collapsed lane takes this and nothing
/// more, and `LaneLayoutMath.standardWidth` divides what is left among the expanded lanes.
///
/// 3.4 em — 44pt at the standard 13pt body, which is the figure the ruling asked for (roughly
/// 4048pt) and the same multiple `nominalCardHeight` uses, so the strip is about as wide as a
/// default card is tall. Wide enough for the lane's glyph, a rotated title and the count badge
/// stacked in a column; narrow enough that a folded lane reads as a spine rather than as a lane.
///
/// **Font-derived like everything else here**, and it has to be: the strip's whole content is
/// type and a badge, so a figure fixed at 44pt would clip its own glyph at a large system text
/// size, and 10-accessibility.md's full-relative-scaling rule would quietly stop holding. It moves
/// with the board's zoom for the same reason every other figure does.
static func collapsedLaneWidth(bodyPointSize: CGFloat) -> CGFloat {
em(3.4, bodyPointSize: bodyPointSize)
}
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities) — the lane accent band's
/// height.
static func laneAccentBandHeight(bodyPointSize: CGFloat) -> CGFloat {
em(0.4, bodyPointSize: bodyPointSize)
}
/// The room the header reserves for the **collapse chevron**, its one piece of trailing chrome
/// (03-board-ui.md § Lane ▸ Collapsed lanes). A new-card button once shared this edge and had its
/// own matching reserve (`newCardButtonReserve`, retired with the button — this milestone); the
/// chevron kept its own figure rather than folding into a since-widowed combined one.
///
/// This is the one figure that is not decoration: the chevron is an `Image` at
/// `.imageScale(.small)`, which *is* a relative size, so a reserve fixed at 18pt would be overrun
/// by the glyph itself at a large system text size and the truncation rule would stop being true.
/// 1.4 em — 18pt at the standard 13pt body — is the glyph plus its breathing room, measured in the
/// same unit it grows in.
static func laneCollapseButtonReserve(bodyPointSize: CGFloat) -> CGFloat {
em(1.4, bodyPointSize: bodyPointSize)
}
/// Everything the lane header's trailing chrome takes — the collapse chevron alone now that the
/// new-card button beside it is gone (this milestone narrowed this from a two-term sum to one).
/// Kept as its own figure rather than inlined at the call site: the one thing the header pads by,
/// so "a long title truncates before it collides" stays one rule however many controls end up
/// living there again.
static func laneHeaderTrailingReserve(bodyPointSize: CGFloat) -> CGFloat {
laneCollapseButtonReserve(bodyPointSize: bodyPointSize)
}
/// The count badge's capsule inset — horizontal and vertical, which are deliberately different:
/// a capsule around a single digit wants width, not height.
static func badgeHorizontalPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.45, bodyPointSize: bodyPointSize)
}
static func badgeVerticalPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.08, bodyPointSize: bodyPointSize)
}
// MARK: - The card face
/// Shared by the plate, the accent stripe and the selection stroke, so the stripe reads as part
/// of the card's edge rather than a bar laid over it.
static func cardCornerRadius(bodyPointSize: CGFloat) -> CGFloat {
em(0.6, bodyPointSize: bodyPointSize)
}
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities). Reserved as padding whether
/// or not a stripe paints, so colouring a card never shifts its title.
static func cardStripeWidth(bodyPointSize: CGFloat) -> CGFloat {
em(0.3, bodyPointSize: bodyPointSize)
}
/// The card plate's inset around its content.
static func cardContentPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.75, bodyPointSize: bodyPointSize)
}
/// Between the icon, the title and the attachments chip.
static func cardRowSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.45, bodyPointSize: bodyPointSize)
}
/// The masonry's spacing — between interior columns and between stacked cards within a column.
///
/// The lane registers this into `LaneDropRegistry.Grid`, so the drop model's analytic resting
/// grid replays the same number the layout drew with and no second derivation exists to drift
/// (DRAG-REORDER.md § The card masonry).
static func cardSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.6, bodyPointSize: bodyPointSize)
}
/// The height a card with no registered measurement is assumed to have — a lane whose faces have
/// not laid out yet, and the shadow a Finder file drop opens for a card that does not exist.
///
/// 3.4 em: one body line of title inside two content paddings, plus the plate's own rhythm.
/// Nominal rather than zero, so the resting rows still tile.
static func nominalCardHeight(bodyPointSize: CGFloat) -> CGFloat {
em(3.4, bodyPointSize: bodyPointSize)
}
// MARK: - The drag replica
/// The width the card drag's replica is drawn at: **the width of the face it was lifted from**.
///
/// A face is as wide as the interior masonry column it sits in — a function of its lane's slot
/// width and its lane's column count (`LaneLayoutMath`, `MasonryLayout`) — so it is not a figure
/// this file can derive at all, only one the live face can report (`CardFaceView` measures it
/// alongside the height the drop model already takes). Drawn at anything else, the image under
/// the cursor is a card the board does not contain, and the pointer sits beside it rather than on
/// it.
///
/// `measured` is that width, and the fallback below is for the face that has not reported one
/// yet.
static func cardReplicaWidth(measured: CGFloat, bodyPointSize: CGFloat) -> CGFloat {
guard measured.isFinite, measured > 0 else { return cardReplicaWidth(bodyPointSize: bodyPointSize) }
return measured
}
/// The replica's fallback width — a card at a representative lane width, for the face that has
/// not laid out yet and so has no width of its own to give.
static func cardReplicaWidth(bodyPointSize: CGFloat) -> CGFloat {
em(17, bodyPointSize: bodyPointSize)
}
/// The transparent margin around a drag replica, which is what keeps its shadow from being
/// clipped by the drag image's bounds.
static func replicaPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.9, bodyPointSize: bodyPointSize)
}
/// The lane replica's floor dimensions, for the frame a lane that has not measured itself yet
/// would otherwise be drawn at.
static func laneReplicaMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
em(6, bodyPointSize: bodyPointSize)
}
static func laneReplicaMinimumHeight(bodyPointSize: CGFloat) -> CGFloat {
em(9, bodyPointSize: bodyPointSize)
}
// MARK: - The lane resize handle
/// The invisible grab strip at a lane's trailing edge, and how far right it is shifted so most
/// of it hangs into the inter-lane gap rather than sitting over the lane's own scrollbar
/// (`LaneResizeHandle`). Both scale, because the gap they live in does.
static func resizeHandleWidth(bodyPointSize: CGFloat) -> CGFloat {
em(0.9, bodyPointSize: bodyPointSize)
}
static func resizeHandleOverhang(bodyPointSize: CGFloat) -> CGFloat {
em(0.6, bodyPointSize: bodyPointSize)
}
// MARK: - The trash column
/// The trash header's inset — horizontal and vertical (03-board-ui.md § Trash ▸ Rendering).
static func trashHeaderHorizontalPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.75, bodyPointSize: bodyPointSize)
}
static func trashHeaderVerticalPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.6, bodyPointSize: bodyPointSize)
}
/// The gap between the trash header's diagonal hatch strokes.
///
/// It scales for a reason the other figures do not share: the hatch is **the trash's non-colour
/// distinction** (10-accessibility.md's never-colour-alone rule — "the trash header is hatched
/// plus labeled"), and a fixed 7pt pitch behind text twice its usual size reads as a texture
/// rather than as hatching.
static func trashHatchSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.55, bodyPointSize: bodyPointSize)
}
// MARK: - The board window
/// The board window's minimum content size — two standard lanes' worth of width and enough
/// height for a header and a few cards. Derived so a large system text size cannot leave the
/// window smaller than one lane's own header.
static func windowMinimumSize(bodyPointSize: CGFloat) -> CGSize {
CGSize(
width: em(49, bodyPointSize: bodyPointSize),
height: em(31, bodyPointSize: bodyPointSize)
)
}
// MARK: - The live metric
/// The body font's point size as the system currently reports it.
///
/// **The app asks the system exactly once, in `CardWindowMetrics.bodyPointSize`**, and this
/// forwards to it: the board and the card window must agree about what "the body font" is, or a
/// card face and the window it opens into would scale on two different rulers.
@MainActor
static var bodyPointSize: CGFloat {
CardWindowMetrics.bodyPointSize
}
}