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:
@@ -335,26 +335,50 @@ struct CardStyleAnchorTests {
|
||||
@Suite("Card sidebar ▸ style editor layout")
|
||||
struct StyleEditorLayoutTests {
|
||||
|
||||
/// How wide `columns` wells and the gaps between them actually draw.
|
||||
private func gridWidth(columns: Int) -> CGFloat {
|
||||
CGFloat(columns) * StyleEditorLayout.wellSide + CGFloat(columns - 1) * StyleEditorLayout.wellSpacing
|
||||
/// How wide `columns` wells and the gaps between them actually draw, at a given text size.
|
||||
private func gridWidth(columns: Int, bodyPointSize: CGFloat) -> CGFloat {
|
||||
CGFloat(columns) * StyleEditorLayout.wellSide(bodyPointSize: bodyPointSize)
|
||||
+ CGFloat(columns - 1) * StyleEditorLayout.wellSpacing(bodyPointSize: bodyPointSize)
|
||||
}
|
||||
|
||||
@Test("The popover anchor is unchanged by the sidebar's arrival")
|
||||
@Test("The popover anchor keeps its settled geometry at the standard text size")
|
||||
func thePopoverKeepsItsSettledGeometry() {
|
||||
// 268 points and 7 + 6 background wells are 03-board-ui.md's own numbers ("narrow enough to
|
||||
// sit beside a card"), and the two anchors that were here first must not have moved because a
|
||||
// third one needed different ones.
|
||||
#expect(StyleEditorLayout.popover.width == 268)
|
||||
#expect(StyleEditorLayout.popover.padding == 14)
|
||||
#expect(StyleEditorLayout.popover.backgroundColumns == 7)
|
||||
#expect(StyleEditorLayout.popover.symbolColumns == 8)
|
||||
#expect(StyleEditorLayout.popover.symbolGridMaximumHeight == 168)
|
||||
// sit beside a card"). Making the anchor font-derived (10-accessibility.md ▸ Text scaling)
|
||||
// must not have moved them at the size they were settled for — the point of the multiples is
|
||||
// that the default text size renders exactly what it always did.
|
||||
let popover = StyleEditorLayout.popover(bodyPointSize: 13)
|
||||
#expect(popover.width == 268)
|
||||
#expect(popover.padding == 14)
|
||||
#expect(popover.backgroundColumns == 7)
|
||||
#expect(popover.symbolColumns == 8)
|
||||
#expect(popover.symbolGridMaximumHeight == 168)
|
||||
#expect(popover.wellSide == 20)
|
||||
#expect(popover.wellSpacing == 6)
|
||||
}
|
||||
|
||||
/// The other half of the same claim: **the popover grows with the text**, so its seven wells
|
||||
/// still fit at a large system text size instead of overflowing a frame frozen at 268 points.
|
||||
@Test("The popover's frame grows with the text, and its wells keep fitting inside it")
|
||||
func thePopoverScales() {
|
||||
var previousWidth: CGFloat = 0
|
||||
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
|
||||
let popover = StyleEditorLayout.popover(bodyPointSize: size)
|
||||
#expect(popover.width! > previousWidth, "the popover must widen with the text at \(size)pt")
|
||||
previousWidth = popover.width!
|
||||
|
||||
// The column counts are the design's and never move; what has to hold is that they
|
||||
// still fit, insets included.
|
||||
#expect(popover.backgroundColumns == 7)
|
||||
let content = popover.width! - 2 * popover.padding
|
||||
#expect(gridWidth(columns: 7, bodyPointSize: size) <= content, "7 wells overflow at \(size)pt")
|
||||
#expect(gridWidth(columns: 8, bodyPointSize: size) <= content, "8 wells overflow at \(size)pt")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("The sidebar anchor takes the column it is given and adds nothing to it")
|
||||
func theSidebarBringsNoGeometryOfItsOwn() {
|
||||
let layout = StyleEditorLayout.sidebar(contentWidth: 169)
|
||||
let layout = StyleEditorLayout.sidebar(contentWidth: 169, bodyPointSize: 13)
|
||||
|
||||
// No width: the sidebar's is `CardWindowMetrics`' one decision. No padding: the section stack
|
||||
// is already inset by a gutter, and insetting twice would narrow the grids for nothing.
|
||||
@@ -369,15 +393,22 @@ struct StyleEditorLayoutTests {
|
||||
func theGridsFitTheSidebar() {
|
||||
for size in [11.0, 13.0, 16.0, 18.0, 24.0, 36.0] as [CGFloat] {
|
||||
let available = CardWindowMetrics.sidebarContentWidth(bodyPointSize: size)
|
||||
let layout = StyleEditorLayout.sidebar(contentWidth: available)
|
||||
let layout = StyleEditorLayout.sidebar(contentWidth: available, bodyPointSize: size)
|
||||
|
||||
#expect(layout.backgroundColumns == layout.symbolColumns, "one column count for one column")
|
||||
// Fits — a grid wider than its column puts the last well of every row out of reach of
|
||||
// the pointer, at a text size nobody checks by eye.
|
||||
#expect(gridWidth(columns: layout.backgroundColumns) <= available, "overflows at \(size)pt")
|
||||
#expect(gridWidth(columns: layout.backgroundColumns, bodyPointSize: size) <= available,
|
||||
"overflows at \(size)pt")
|
||||
// And is maximal: one more well would not have fitted, so the wells are as large a set as
|
||||
// the column can show rather than an arbitrary count that happened to be safe.
|
||||
#expect(gridWidth(columns: layout.backgroundColumns + 1) > available, "under-packed at \(size)pt")
|
||||
#expect(gridWidth(columns: layout.backgroundColumns + 1, bodyPointSize: size) > available,
|
||||
"under-packed at \(size)pt")
|
||||
// And the count is *stable* across text sizes, because the column and the wells scale on
|
||||
// the same ruler — the sidebar is 26 body characters wide and a well is 1.55 body ems,
|
||||
// and neither of those ratios moves. Without that the grid would collapse to a strip at
|
||||
// a large text size, which is a palette the user can no longer scan.
|
||||
#expect(layout.backgroundColumns >= 4, "the grid collapsed to a strip at \(size)pt")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,8 +416,11 @@ struct StyleEditorLayoutTests {
|
||||
func theSidebarIsTheNarrowerAnchor() {
|
||||
// Which is the entire reason this type exists: the popover's 7 wells across do not fit a
|
||||
// 26-character column, so an editor with one hard-coded frame could not have both anchors.
|
||||
let layout = StyleEditorLayout.sidebar(contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: 13))
|
||||
#expect(layout.backgroundColumns < StyleEditorLayout.popover.backgroundColumns)
|
||||
let layout = StyleEditorLayout.sidebar(
|
||||
contentWidth: CardWindowMetrics.sidebarContentWidth(bodyPointSize: 13),
|
||||
bodyPointSize: 13
|
||||
)
|
||||
#expect(layout.backgroundColumns < StyleEditorLayout.popover(bodyPointSize: 13).backgroundColumns)
|
||||
#expect(layout.backgroundColumns >= 4, "a grid this narrow would be a strip, not a palette")
|
||||
}
|
||||
|
||||
@@ -395,9 +429,10 @@ struct StyleEditorLayoutTests {
|
||||
// Total over any width, because the caller is a layout system: a zero proposal during a
|
||||
// window's first frame must not produce a grid of zero columns, which is a division by zero
|
||||
// waiting in `LazyVGrid`.
|
||||
#expect(StyleEditorLayout.columns(fitting: 0) == 1)
|
||||
#expect(StyleEditorLayout.columns(fitting: -100) == 1)
|
||||
#expect(StyleEditorLayout.columns(fitting: StyleEditorLayout.wellSide) == 1)
|
||||
#expect(StyleEditorLayout.columns(fitting: 0, bodyPointSize: 13) == 1)
|
||||
#expect(StyleEditorLayout.columns(fitting: -100, bodyPointSize: 13) == 1)
|
||||
#expect(StyleEditorLayout.columns(fitting: StyleEditorLayout.wellSide(bodyPointSize: 13),
|
||||
bodyPointSize: 13) == 1)
|
||||
}
|
||||
|
||||
@Test("The sidebar's content width is the column minus its two gutters")
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reduce Transparency
|
||||
|
||||
@Suite("Accommodations ▸ Reduce Transparency")
|
||||
struct ReduceTransparencyTests {
|
||||
|
||||
/// "Glass underlays go solid, wherever they appear" (10-accessibility.md). The board's one
|
||||
/// surviving material is the transient search bar's `.bar` — the design's own example, the card
|
||||
/// face carousel's page dots, died with the carousel (03-board-ui.md § Card face).
|
||||
@Test("The one glass underlay goes solid")
|
||||
func glassGoesSolid() {
|
||||
#expect(Accommodations.underlay(reduceTransparency: false) == .glass)
|
||||
#expect(Accommodations.underlay(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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user