Files
lanework/KanbanTests/VisualAccommodationsTests.swift
T
rzen ce92c24190 Hero image for cards — one of the card's own attachments, banded across its face
A card whose `hero:` names one of its own attachments draws that picture as a
banner across the full width of its plate, above the icon-and-title row,
aspect-fill cropped into a fixed 2.75 em band — 36pt at the standard body, and
em-scaled like every other figure the board draws, so it grows with the system
text size and with the board's zoom rather than shrinking against a title twice
its usual size. The figure sits deliberately under the 44pt a plain one-line
card is tall: a hero card should read as a card with a picture on it rather than
a picture with a caption, which is 03's standing rule that the title dominates.

The key's grammar is a **bare filename**, and that is what separates it from the
board background's `image` subkey rather than a nervousness about paths. A board
names a file anywhere under its root, so a path is that key's reading and where
it leads is the renderer's question. A card names one of the files it already
owns — the flat `attachments/` folder the app lists, relocates into, and carries
through every move, copy, trash and restore — so `hero: art/sketch.png` is not an
awkward spelling of a hero image, it is a value the key cannot mean. It therefore
has no reading at all: a value carrying a separator, or spelling `.`/`..`, or
empty, is malformed at the document layer, which renders it as absent and leaves
the coerce tier's trace, exactly as `width: 1.5` does. The bytes stay as written,
the resolver re-checks containment anyway, and the whole degrade family below
that — a name pointing at a missing file, an unreadable one, or one that is not
an image — ends the same way: no banner, no defect, nothing written.

That last promise is about *height* as much as about ink, so the band is given no
height at all until a picture has actually decoded. A card whose hero cannot be
drawn lays out identically to a card with no key, structurally rather than by a
branch somebody has to remember; the price is one settle per hero as a board
opens, and none after that. Everything else the face draws is attached outside
the new stack and is untouched by it — the accent stripe still runs the plate's
full leading edge across the band's corner, the selection and file-hover strokes
still ring the whole plate, the cut and drag dims still cover it, and the drop
model still registers the plate's real height, so a hero card is simply a taller
card the masonry already understands. The trash draws it too, by the one-face
rule.

Decoding is ImageIO's downsampling path off the main actor at a quarter of the
backdrop's pixel budget (`BoardBackdrop.decode` gained the limit as a parameter
rather than being copied), and the results live in one app-wide, deliberately
non-observable cache keyed on path plus the file's date and size. Non-observable
because a tracked write there would invalidate every hero face on the board,
which is the O(board) invalidation this view was rebuilt once already to shed;
each face holds its own picture in view state and seeds it from the cache, which
is also what lets the drag replica — whose preview builder is non-escaping and
cannot await anything — carry the band at the face's real height. Taking a stamp
twice from one URL value turned out to answer with the first read's date and size
however many times the bytes had changed, so `stamp(of:)` now drops its cached
resource values first; noticing a replacement is the only thing a stamp is for.

The face takes the resolved URL as a compared input rather than resolving it, for
selected-ness's reason one axis over: resolving needs the card's folder, which a
face does not know, and finding it from the snapshot would be a board walk per
face. The lane and the trash column each know their own container and compute it
once for the whole strip.

There is no in-app setter this version — the key is written by hand or by an
agent, which is why the guide bumps to v13 with a clause spelling the grammar out
beside the other card keys, and why `attachments/` gets the one-line pointer an
agent that has just written `![](attachments/x.png)` will need. "Set as Hero"
from the attachment row is future work, as is the card window and print, which
draw the same model and show no banner today.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-08 23:41:15 -04:00

330 lines
19 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 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)
// The fold's own figure (03-board-ui.md § Lane ▸ Collapsed lanes): the strip is the ruling's
// ~44pt, and the chevron's reserve is 18 — which is also the header's whole trailing budget
// now that the new-card button that once shared it is gone (m-remove-header-add).
#expect(BoardMetrics.collapsedLaneWidth(bodyPointSize: size) == 44)
#expect(BoardMetrics.laneCollapseButtonReserve(bodyPointSize: size) == 18)
#expect(BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: size) == 18)
#expect(BoardMetrics.cardCornerRadius(bodyPointSize: size) == 8)
#expect(BoardMetrics.cardStripeWidth(bodyPointSize: size) == 4)
// The hero band (03-board-ui.md § Card face ▸ Hero image) — 36pt at the standard body, inside
// the ruling's 2.53× and deliberately under the 44pt a plain one-line card is tall, so the
// title still dominates the face it bands.
#expect(BoardMetrics.cardHeroHeight(bodyPointSize: size) == 36)
#expect(BoardMetrics.cardHeroHeight(bodyPointSize: size)
< BoardMetrics.nominalCardHeight(bodyPointSize: size))
#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) }),
("collapsedLaneWidth", { BoardMetrics.collapsedLaneWidth(bodyPointSize: $0) }),
("laneCollapseButtonReserve", { BoardMetrics.laneCollapseButtonReserve(bodyPointSize: $0) }),
("laneHeaderTrailingReserve", { BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: $0) }),
("badgeHorizontalPadding", { BoardMetrics.badgeHorizontalPadding(bodyPointSize: $0) }),
("cardCornerRadius", { BoardMetrics.cardCornerRadius(bodyPointSize: $0) }),
("cardStripeWidth", { BoardMetrics.cardStripeWidth(bodyPointSize: $0) }),
("cardContentPadding", { BoardMetrics.cardContentPadding(bodyPointSize: $0) }),
("cardHeroHeight", { BoardMetrics.cardHeroHeight(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 header's trailing 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 collapse chevron, 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-point-size failure this
/// conversion exists to remove: at 24pt the glyph alone approaches a stale fixed reserve. (Once
/// the sum of two glyphs' room — the new-card button and the chevron — this is now the chevron's
/// alone, the button having been removed.)
@Test("The header's trailing reserve stays wider than the glyph it reserves for")
func theTrailingReserveOutgrowsItsGlyph() {
for size in Self.sizes {
#expect(BoardMetrics.laneHeaderTrailingReserve(bodyPointSize: size) > size,
"the reserve is narrower than one em at \(size)pt")
#expect(
BoardMetrics.laneHeaderTrailingReserve(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)
}
}