The whole-lane wash-and-solid-ring scaled its emphasis with the lane's area: what read as a ring on a card read as a slab on a column. Resettled as the system focus ring's vocabulary at selection strength — a hairline accent edge with a soft accent glow bleeding outward, riding the stroke shape rather than the translucent plate (a shadow behind it would bleed through as murk), and no wash at all: nothing tints the cards' backdrop, and lane colour stays the accent band's alone. Under Increase Contrast the halo yields to the solid full-alpha ring — "strengthens borders" means crisper, and a blur is the one thing a border cannot become — so the setting sees exactly the indicator it always has; cards and trash rows are card-scale and keep the solid ring at every contrast. One selection vocabulary, pinned by Accommodations.drawsSelectionHalo and its test. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
314 lines
18 KiB
Swift
314 lines
18 KiB
Swift
import CoreGraphics
|
||
import SwiftUI
|
||
import Testing
|
||
@testable import Kanban
|
||
|
||
/// The two seams m11's visual-accommodations card added, and the one it made font-derived
|
||
/// (10-accessibility.md ▸ Text scaling & visual accommodations):
|
||
///
|
||
/// 1. **`BoardMetrics`** — the board strip's geometry as a pure function of the body point size, so
|
||
/// "Card face, lane header, and masonry metrics derive from font metrics, so layout survives the
|
||
/// largest system text sizes" is a fact a suite can assert rather than something to be verified by
|
||
/// eye at three text sizes. Nothing here renders; what is pinned is that the numbers *move*, that
|
||
/// they move in the right direction, and that they land on the board's settled figures at the
|
||
/// standard 13pt body — which is the whole claim that making the board scale did not redesign it.
|
||
/// 2. **`Accommodations`** — Increase Contrast and Reduce Transparency as decisions separable from
|
||
/// the drawing, `Motion`'s pattern for its reason (`AnyShapeStyle` is opaque, so a claim about a
|
||
/// fill is only testable through a small `Equatable` enum).
|
||
/// 3. **`LaneLayoutMath` under a scaled gap** — the no-horizontal-scroll invariant, re-asserted at
|
||
/// the text sizes the gap now varies over. It is 03-board-ui.md's headline layout rule and the one
|
||
/// thing a font-derived gap could plausibly have broken.
|
||
|
||
// MARK: - The board's font-derived geometry
|
||
|
||
@Suite("Board metrics ▸ the standard text size is unchanged")
|
||
struct BoardMetricsSettledFiguresTests {
|
||
|
||
/// The system body font is 13pt at the standard macOS text size, and every multiple in
|
||
/// `BoardMetrics` was chosen to reproduce the board's existing numbers there.
|
||
///
|
||
/// This is the load-bearing test of the whole conversion: the milestone's mandate was to make the
|
||
/// board *scale*, not to move it, so a default-text-size board must lay out on exactly the
|
||
/// figures it laid out on before. A drift here is a visual regression nothing else would catch.
|
||
@Test("Every figure lands on the board's settled number at 13pt")
|
||
func theStandardSizeReproducesTheSettledFigures() {
|
||
let size: CGFloat = 13
|
||
#expect(BoardMetrics.stripGap(bodyPointSize: size) == 12)
|
||
#expect(BoardMetrics.laneCornerRadius(bodyPointSize: size) == 10)
|
||
#expect(BoardMetrics.lanePlatePadding(bodyPointSize: size) == 6)
|
||
#expect(BoardMetrics.laneStackSpacing(bodyPointSize: size) == 8)
|
||
#expect(BoardMetrics.laneHeaderSpacing(bodyPointSize: size) == 6)
|
||
#expect(BoardMetrics.laneAccentBandHeight(bodyPointSize: size) == 5)
|
||
#expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) == 22)
|
||
#expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
|
||
#expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4)
|
||
#expect(BoardMetrics.cardContentPadding(bodyPointSize: size) == 10)
|
||
#expect(BoardMetrics.cardRowSpacing(bodyPointSize: size) == 6)
|
||
#expect(BoardMetrics.cardSpacing(bodyPointSize: size) == 8)
|
||
#expect(BoardMetrics.nominalCardHeight(bodyPointSize: size) == 44)
|
||
#expect(BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: size) == 10)
|
||
#expect(BoardMetrics.trashHeaderVerticalPadding(bodyPointSize: size) == 8)
|
||
#expect(BoardMetrics.trashHatchSpacing(bodyPointSize: size) == 7)
|
||
#expect(BoardMetrics.resizeHandleWidth(bodyPointSize: size) == 12)
|
||
#expect(BoardMetrics.resizeHandleOverhang(bodyPointSize: size) == 8)
|
||
}
|
||
|
||
/// The board window's floor is the one 02/03 never named in points but that the host has always
|
||
/// carried — 640 × 400, reproduced at the standard size.
|
||
@Test("The window minimum reproduces its settled size at 13pt")
|
||
func theWindowMinimumIsUnchanged() {
|
||
let minimum = BoardMetrics.windowMinimumSize(bodyPointSize: 13)
|
||
#expect(minimum.width == 637)
|
||
#expect(minimum.height == 403)
|
||
}
|
||
}
|
||
|
||
@Suite("Board metrics ▸ everything scales")
|
||
struct BoardMetricsScalingTests {
|
||
|
||
/// The text sizes the suite sweeps: below the standard, the standard, and the range a user who
|
||
/// has turned the system text size up actually lands in.
|
||
private static let sizes: [CGFloat] = [11, 13, 16, 18, 24, 36]
|
||
|
||
/// **The whole point of the type.** Every figure grows with the text size — which is what
|
||
/// "layout survives the largest system text sizes" means arithmetically: a card's padding cannot
|
||
/// stay at 10 points while the title inside it doubles.
|
||
///
|
||
/// Two claims, because rounding to whole points makes them different claims. **Never smaller**
|
||
/// between adjacent sizes — a figure that shrank as the text grew would be a bug — and
|
||
/// **strictly larger** across the whole range, which is what separates a real derivation from a
|
||
/// fixed point size wearing a function's clothes. Adjacent sizes may legitimately share a value
|
||
/// for the smallest figures (0.3 em of a 16pt body and of an 18pt body both round to 5), and
|
||
/// that is the rounding doing its job: a hairline has no fractional setting worth having.
|
||
@Test("Every metric grows with the body point size")
|
||
func everyMetricIsMonotone() {
|
||
let metrics: [(String, (CGFloat) -> CGFloat)] = [
|
||
("stripGap", { BoardMetrics.stripGap(bodyPointSize: $0) }),
|
||
("laneCornerRadius", { BoardMetrics.laneCornerRadius(bodyPointSize: $0) }),
|
||
("lanePlatePadding", { BoardMetrics.lanePlatePadding(bodyPointSize: $0) }),
|
||
("laneStackSpacing", { BoardMetrics.laneStackSpacing(bodyPointSize: $0) }),
|
||
("laneHeaderSpacing", { BoardMetrics.laneHeaderSpacing(bodyPointSize: $0) }),
|
||
("laneHeaderInset", { BoardMetrics.laneHeaderInset(bodyPointSize: $0) }),
|
||
("laneAccentBandHeight", { BoardMetrics.laneAccentBandHeight(bodyPointSize: $0) }),
|
||
("newCardButtonReserve", { BoardMetrics.newCardButtonReserve(bodyPointSize: $0) }),
|
||
("badgeHorizontalPadding", { BoardMetrics.badgeHorizontalPadding(bodyPointSize: $0) }),
|
||
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
|
||
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
|
||
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
|
||
("cardRowSpacing", { BoardMetrics.cardRowSpacing(bodyPointSize: $0) }),
|
||
("cardSpacing", { BoardMetrics.cardSpacing(bodyPointSize: $0) }),
|
||
("nominalCardHeight", { BoardMetrics.nominalCardHeight(bodyPointSize: $0) }),
|
||
("cardReplicaWidth", { BoardMetrics.cardReplicaWidth(bodyPointSize: $0) }),
|
||
("replicaPadding", { BoardMetrics.replicaPadding(bodyPointSize: $0) }),
|
||
("trashHeaderHorizontalPadding", { BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: $0) }),
|
||
("trashHatchSpacing", { BoardMetrics.trashHatchSpacing(bodyPointSize: $0) }),
|
||
("resizeHandleWidth", { BoardMetrics.resizeHandleWidth(bodyPointSize: $0) }),
|
||
]
|
||
for (name, metric) in metrics {
|
||
for (smaller, larger) in zip(Self.sizes, Self.sizes.dropFirst()) {
|
||
#expect(metric(smaller) <= metric(larger),
|
||
"\(name) shrank from \(smaller)pt to \(larger)pt")
|
||
}
|
||
#expect(metric(Self.sizes.first!) < metric(Self.sizes.last!),
|
||
"\(name) is fixed across the whole range")
|
||
}
|
||
}
|
||
|
||
/// **The new-card button's reserve stays ahead of the header's own furniture.**
|
||
///
|
||
/// 03-board-ui.md's graceful-truncation rule says a long lane title truncates rather than
|
||
/// colliding with the button, and the reserve is what makes that true. It has to stay wider than
|
||
/// a glyph-and-margin at every size, which is the fixed-22pt failure this conversion exists to
|
||
/// remove: at 24pt the glyph alone approaches the old reserve.
|
||
@Test("The header's button reserve stays wider than the glyph it reserves for")
|
||
func theButtonReserveOutgrowsItsGlyph() {
|
||
for size in Self.sizes {
|
||
#expect(BoardMetrics.newCardButtonReserve(bodyPointSize: size) > size,
|
||
"the reserve is narrower than one em at \(size)pt")
|
||
#expect(
|
||
BoardMetrics.newCardButtonReserve(bodyPointSize: size)
|
||
> BoardMetrics.laneHeaderSpacing(bodyPointSize: size),
|
||
"the reserve is narrower than the header's own spacing at \(size)pt"
|
||
)
|
||
}
|
||
}
|
||
|
||
/// The card plate's proportions hold at every size — the stripe stays a stripe rather than
|
||
/// becoming a band, and the plate's inset stays wider than the stripe it sits beside (which is
|
||
/// what keeps "colouring a card never shifts its title" from becoming "colouring a card eats its
|
||
/// title").
|
||
@Test("The card plate keeps its proportions at every text size")
|
||
func theCardPlateKeepsItsProportions() {
|
||
for size in Self.sizes {
|
||
let stripe = BoardMetrics.cardStripeWidth(bodyPointSize: size)
|
||
let padding = BoardMetrics.cardContentPadding(bodyPointSize: size)
|
||
let radius = BoardMetrics.cardCornerRadius(bodyPointSize: size)
|
||
#expect(stripe < padding, "the stripe is wider than the plate's inset at \(size)pt")
|
||
#expect(stripe < radius, "the stripe is wider than the corner it rounds into at \(size)pt")
|
||
#expect(BoardMetrics.nominalCardHeight(bodyPointSize: size) > 2 * padding + size,
|
||
"the nominal height cannot hold one line of title at \(size)pt")
|
||
}
|
||
}
|
||
|
||
/// `em` is total and never yields a non-positive length: a zero-width stripe or a zero-height
|
||
/// band is a shape SwiftUI is asked to draw and cannot, and the pathological inputs (a
|
||
/// degenerate point size, a vanishing multiple) have to land somewhere.
|
||
@Test("The unit is floored at one point, whatever it is handed")
|
||
func theUnitIsTotal() {
|
||
#expect(BoardMetrics.em(0.3, bodyPointSize: 0) == 1)
|
||
#expect(BoardMetrics.em(0, bodyPointSize: 13) == 1)
|
||
#expect(BoardMetrics.em(-1, bodyPointSize: 13) == 1)
|
||
#expect(BoardMetrics.em(0.01, bodyPointSize: 13) == 1)
|
||
}
|
||
}
|
||
|
||
// MARK: - The no-horizontal-scroll invariant, under a gap that moves
|
||
|
||
@Suite("Board metrics ▸ the strip still never scrolls")
|
||
struct ScaledStripDivisionTests {
|
||
|
||
/// **The invariant 03-board-ui.md § Layout — full visibility is built on, re-checked now that
|
||
/// the gap varies with the text size.**
|
||
///
|
||
/// The whole strip — `totalUnits` standard widths plus `totalUnits + 1` gaps — must fill the
|
||
/// window's width exactly, at every text size and every lane count. A larger text size therefore
|
||
/// buys a larger gap out of the lanes' own width: the lanes compress, the strip does not grow,
|
||
/// and horizontal scroll never appears. That is "the degenerate case is accepted, not floored",
|
||
/// holding through the scaling change rather than despite it.
|
||
@Test("The lanes plus the gaps fill the window exactly at every text size")
|
||
func theDivisionIsExactAtEveryTextSize() {
|
||
let stripWidth: CGFloat = 1400
|
||
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
|
||
let gap = BoardMetrics.stripGap(bodyPointSize: size)
|
||
for units in 1...12 {
|
||
let standard = LaneLayoutMath.standardWidth(
|
||
stripWidth: stripWidth, totalUnits: units, gap: gap)
|
||
let drawn = standard * CGFloat(units) + gap * CGFloat(units + 1)
|
||
#expect(abs(drawn - stripWidth) < 0.001,
|
||
"\(units) units at \(size)pt drew \(drawn) into \(stripWidth)")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The same window and the same lane count: a **larger** text size means **narrower** lanes,
|
||
/// never a wider strip. This is the direction the invariant depends on — the alternative would be
|
||
/// a strip that overflowed its window and had to scroll.
|
||
@Test("A larger text size narrows the lanes rather than widening the strip")
|
||
func aLargerTextSizeCompressesTheLanes() {
|
||
let stripWidth: CGFloat = 1400
|
||
var previous = CGFloat.greatestFiniteMagnitude
|
||
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
|
||
let standard = LaneLayoutMath.standardWidth(
|
||
stripWidth: stripWidth,
|
||
totalUnits: 5,
|
||
gap: BoardMetrics.stripGap(bodyPointSize: size)
|
||
)
|
||
#expect(standard < previous, "lanes did not compress at \(size)pt")
|
||
previous = standard
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Increase Contrast
|
||
|
||
@Suite("Accommodations ▸ Increase Contrast")
|
||
struct IncreaseContrastTests {
|
||
|
||
/// "Increase Contrast strengthens borders and the selection indicator" (10-accessibility.md), as
|
||
/// one flat point rather than a factor — see `Accommodations.borderWidth` for why.
|
||
@Test("A stroke goes one point heavier under Increase Contrast")
|
||
func aStrokeGoesHeavier() {
|
||
for base in [1.0, 1.5, 2.0, 2.5, 3.0] as [CGFloat] {
|
||
#expect(Accommodations.borderWidth(base, contrast: .standard) == base)
|
||
#expect(Accommodations.borderWidth(base, contrast: .increased) == base + 1)
|
||
}
|
||
}
|
||
|
||
/// The widths encode a hierarchy — a hovered card reads heavier than a selected one, a selected
|
||
/// tile heavier than an unselected one — and the setting must not flatten it. A flat addend is
|
||
/// order-preserving; a multiplier applied to only some sites would not have been.
|
||
@Test("Strengthening preserves the hierarchy the widths encode")
|
||
func strengtheningIsOrderPreserving() {
|
||
let ordered: [CGFloat] = [1, 1.5, 2, 2.5, 3]
|
||
for contrast in [ColorSchemeContrast.standard, .increased] {
|
||
let strengthened = ordered.map { Accommodations.borderWidth($0, contrast: contrast) }
|
||
#expect(strengthened == strengthened.sorted(), "the order collapsed under \(contrast)")
|
||
#expect(Set(strengthened).count == ordered.count, "two widths merged under \(contrast)")
|
||
}
|
||
}
|
||
|
||
/// The half that is easy to miss: a card plate and a lane plate carry **no** resting border, so
|
||
/// "this is one card and that is another" is carried by a fill boundary alone — exactly the
|
||
/// distinction the setting exists to rescue. Under Increase Contrast they gain one.
|
||
@Test("Resting plates gain an outline only under Increase Contrast")
|
||
func restingPlatesGainAnOutline() {
|
||
#expect(!Accommodations.drawsRestingBorder(contrast: .standard))
|
||
#expect(Accommodations.drawsRestingBorder(contrast: .increased))
|
||
}
|
||
|
||
/// Alpha is the other way a border can be weak. A width bump alone would leave the marquee's
|
||
/// 50%-alpha edge and the drag shadow's 55%-alpha dashes just as hard to see, two points wider.
|
||
@Test("A faded accent goes to full strength under Increase Contrast")
|
||
func afadedAccentGoesFull() {
|
||
for base in [0.5, 0.55, 0.6] {
|
||
#expect(Accommodations.accentOpacity(base, contrast: .standard) == base)
|
||
#expect(Accommodations.accentOpacity(base, contrast: .increased) == 1)
|
||
}
|
||
}
|
||
|
||
/// The selected lane's glow is the resting treatment's softness, not the indicator itself:
|
||
/// under Increase Contrast it yields to the solid full-alpha ring, because "strengthens
|
||
/// borders" means crisper and a blur is the one thing a border cannot become.
|
||
@Test("The selection halo yields to Increase Contrast")
|
||
func theHaloYieldsToIncreaseContrast() {
|
||
#expect(Accommodations.drawsSelectionHalo(contrast: .standard))
|
||
#expect(!Accommodations.drawsSelectionHalo(contrast: .increased))
|
||
}
|
||
}
|
||
|
||
// MARK: - Reduce Transparency
|
||
|
||
@Suite("Accommodations ▸ Reduce Transparency")
|
||
struct ReduceTransparencyTests {
|
||
|
||
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board carries
|
||
/// two materials — the transient search bar's `.bar` and the backdrop's title-bar frost — and
|
||
/// the rule is one rule: both take the same solid, whatever their weights without it.
|
||
@Test("Both glass underlays go solid")
|
||
func glassGoesSolid() {
|
||
#expect(Accommodations.underlay(reduceTransparency: false) == .glass)
|
||
#expect(Accommodations.underlay(reduceTransparency: true) == .solid)
|
||
#expect(Accommodations.frost(reduceTransparency: false) == .frost)
|
||
#expect(Accommodations.frost(reduceTransparency: true) == .solid)
|
||
}
|
||
|
||
/// The washes are not glass — they composite at an alpha rather than sampling a backdrop — but
|
||
/// they fail the same way for the same user, because what is behind them is a colour the *user*
|
||
/// chose (03-board-ui.md § Styling). Each one goes opaque under the setting, and each keeps its
|
||
/// own weight without it: the trash header is heavier than its plate, because the header is the
|
||
/// whole of "you are looking at the trash".
|
||
@Test("Every translucent wash goes opaque, and keeps its weight otherwise")
|
||
func washesGoOpaque() {
|
||
#expect(Accommodations.trashPlateWash(reduceTransparency: true) == .opaque)
|
||
#expect(Accommodations.trashHeaderWash(reduceTransparency: true) == .opaque)
|
||
#expect(Accommodations.dragShadowWash(reduceTransparency: true) == .opaque)
|
||
|
||
#expect(Accommodations.trashPlateWash(reduceTransparency: false) == .translucent(opacity: 0.35))
|
||
#expect(Accommodations.trashHeaderWash(reduceTransparency: false) == .translucent(opacity: 0.5))
|
||
#expect(Accommodations.dragShadowWash(reduceTransparency: false) == .translucent(opacity: 0.5))
|
||
}
|
||
|
||
/// The trash column's two surfaces are a pair, and the pair has to stay legible as one: the
|
||
/// header must read as heavier than the plate under it, or the column stops announcing itself.
|
||
@Test("The trash header stays heavier than its plate")
|
||
func theTrashHeaderStaysHeavier() {
|
||
guard case let .translucent(header) = Accommodations.trashHeaderWash(reduceTransparency: false),
|
||
case let .translucent(plate) = Accommodations.trashPlateWash(reduceTransparency: false)
|
||
else {
|
||
Issue.record("both washes should be translucent without the setting")
|
||
return
|
||
}
|
||
#expect(header > plate)
|
||
}
|
||
}
|