Implement visual accommodations and Full Keyboard Access

Full relative text scaling per DESIGN/10: BoardMetrics is the board
strip's geometry as a pure function of the body point size
(CardWindowMetrics' twin) — lane plate/header/band, card
corner/stripe/padding, masonry spacing, the drop model's nominal card
height, resize-handle geometry, trash hatch pitch, and both window
floors all derive from an em; CardFaceMetrics folded in. The two fixed
font sizes (welcome brand/glyph) went relative; the toolbar search
field is 17 ems like the transient bar's. The no-horizontal-scroll
invariant is pinned by test at six text sizes by twelve lane counts.

Accommodations is Motion's sibling for the visual settings: Increase
Contrast adds a flat point to strokes (monotone, hierarchy-preserving),
gives borderless card/lane plates a resting separator hairline, and
takes faded accents to full alpha; Reduce Transparency turns the
transient search bar's glass solid and does the same for the alpha
washes that composite over a user-chosen board background (trash plate,
hatched header, drag shadow). Reduce Motion audited — every animated
surface already routes through Motion with a reduced variant; no gaps.

Full Keyboard Access: the template chooser's tiles were pointer-only —
now focusable, arrow-navigable (clamped, StyleWellGrid's rule), Space
picks, Return stays the sheet's default action, focus names the
selection one-way. The board's single tab stop shows its focus ring
under FKA (focusEffectDisabled inverts). Style editor verified already
conformant. Edge accents verified text-free; trash hatch pitch now
font-derived so it still reads as hatching at large text.

1549 unit tests green, both schemes build.

Claude-Session: https://claude.ai/code/session_01SR4XGjmBE16ZUYWpfFHXwY
This commit is contained in:
2026-07-29 08:48:25 -04:00
parent c339b4cecf
commit 8564814754
21 changed files with 1419 additions and 218 deletions
+246
View File
@@ -0,0 +1,246 @@
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)
}
/// 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 trailing room the header reserves for the new-card button, so **a long title truncates
/// before it collides with the button** rather than running under it.
///
/// This is the one figure that is not decoration: the button is an `Image` at
/// `.imageScale(.small)`, which *is* a relative size, so a reserve fixed at 22pt would be
/// overrun by the glyph itself at a large system text size and the truncation rule would stop
/// being true. 1.7 em is the button plus its breathing room, measured in the same unit the glyph
/// grows in.
static func newCardButtonReserve(bodyPointSize: CGFloat) -> CGFloat {
em(1.7, 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 a card at a representative lane width, since
/// the image under the cursor has no lane to measure itself against.
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
}
}