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:
@@ -69,15 +69,24 @@ struct LaneView: View {
|
||||
/// and handed to `Motion`, which owns what "reduced" means.
|
||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||
|
||||
/// Increase Contrast, for the selection stroke and the plate's resting edge below
|
||||
/// (10-accessibility.md). Read from the environment and handed to `Accommodations`, which owns
|
||||
/// what "increased" does to a stroke.
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
/// The live body metric — every figure this lane lays out on is a multiple of it
|
||||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule).
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
|
||||
/// Spacing between cards, and between the interior columns.
|
||||
private let cardSpacing: CGFloat = 8
|
||||
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
|
||||
|
||||
/// 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.
|
||||
private let cornerRadius: CGFloat = 10
|
||||
private var cornerRadius: CGFloat { BoardMetrics.laneCornerRadius(bodyPointSize: pointSize) }
|
||||
|
||||
/// C7 · full-column top edge (03-board-ui.md § Styling ▸ Capabilities).
|
||||
private let bandHeight: CGFloat = 5
|
||||
private var bandHeight: CGFloat { BoardMetrics.laneAccentBandHeight(bodyPointSize: pointSize) }
|
||||
|
||||
/// The lane's drawn height, for the replica. Measured rather than derived, because a lane is as
|
||||
/// tall as the strip gives it.
|
||||
@@ -92,11 +101,11 @@ struct LaneView: View {
|
||||
// lane's top edge, so it must sit outside the content inset rather than in it.
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
accentBand
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
||||
header
|
||||
cardStack
|
||||
}
|
||||
.padding(6)
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
}
|
||||
.background(selectionBackground)
|
||||
.overlay(selectionStroke)
|
||||
@@ -331,7 +340,7 @@ struct LaneView: View {
|
||||
}
|
||||
|
||||
private var headerContent: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(lane.icon, fallback: ItemSymbol.lane))
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
@@ -340,9 +349,12 @@ struct LaneView: View {
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
// Reserves the button's width so a long title truncates before it collides, and keeps the
|
||||
// button out of the gestured region.
|
||||
.padding(.trailing, 22)
|
||||
.padding(.horizontal, 4)
|
||||
// button out of the gestured region. **Font-derived** rather than a fixed 22pt: the button
|
||||
// is an `Image` at a relative image scale, so a fixed reserve would be overrun by the glyph
|
||||
// itself at a large system text size and 03-board-ui.md's graceful-truncation rule would
|
||||
// quietly stop holding (`BoardMetrics.newCardButtonReserve`).
|
||||
.padding(.trailing, BoardMetrics.newCardButtonReserve(bodyPointSize: pointSize))
|
||||
.padding(.horizontal, BoardMetrics.laneHeaderInset(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
/// The title, or the rename editor when this lane is the one being renamed.
|
||||
@@ -393,8 +405,8 @@ struct LaneView: View {
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 1)
|
||||
.padding(.horizontal, BoardMetrics.badgeHorizontalPadding(bodyPointSize: pointSize))
|
||||
.padding(.vertical, BoardMetrics.badgeVerticalPadding(bodyPointSize: pointSize))
|
||||
.background(Capsule().fill(.quaternary))
|
||||
}
|
||||
|
||||
@@ -485,7 +497,7 @@ struct LaneView: View {
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
private var draggedLaneCount: Int {
|
||||
@@ -497,11 +509,12 @@ struct LaneView: View {
|
||||
private var replicaFace: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
accentBand
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: BoardMetrics.laneStackSpacing(bodyPointSize: pointSize)) {
|
||||
headerContent
|
||||
VStack(alignment: .leading, spacing: cardSpacing) {
|
||||
ForEach(renderedCards.prefix(12)) { card in
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline,
|
||||
spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
@@ -510,16 +523,23 @@ struct LaneView: View {
|
||||
.lineLimit(2)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(10)
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: 8).fill(.background.secondary))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
.fill(.background.secondary)
|
||||
)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.padding(6)
|
||||
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
|
||||
}
|
||||
.frame(width: max(slotWidth, 80), height: max(measuredHeight, 120), alignment: .topLeading)
|
||||
.frame(
|
||||
width: max(slotWidth, BoardMetrics.laneReplicaMinimumWidth(bodyPointSize: pointSize)),
|
||||
height: max(measuredHeight, BoardMetrics.laneReplicaMinimumHeight(bodyPointSize: pointSize)),
|
||||
alignment: .topLeading
|
||||
)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background))
|
||||
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||
}
|
||||
@@ -567,7 +587,7 @@ struct LaneView: View {
|
||||
case let .shadow(_, height):
|
||||
// One of the drag's N contiguous shadows, at the dragged card's frozen
|
||||
// height — the run's real footprint, so the drop lands exactly here.
|
||||
DragShadow(cornerRadius: 8)
|
||||
DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
.frame(height: height)
|
||||
}
|
||||
}
|
||||
@@ -806,13 +826,33 @@ struct LaneView: View {
|
||||
/// 03-board-ui.md gives lane *colour* to the top-edge accent band, so selection must not read as
|
||||
/// a fill that would compete with it once that lands.
|
||||
private var selectionBackground: some View {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.fill(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.08)) : AnyShapeStyle(.clear))
|
||||
}
|
||||
|
||||
/// The selection ring — and, under Increase Contrast, the plate's resting edge as well
|
||||
/// (10-accessibility.md: "Increase Contrast strengthens borders and the selection indicator";
|
||||
/// `Accommodations`, and `CardFaceView.plateStroke` for the same three-way branch on a card).
|
||||
///
|
||||
/// A lane is otherwise bounded by nothing but the gap between it and its neighbour, which is the
|
||||
/// distinction the setting most needs to restore here.
|
||||
private var selectionStroke: some View {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(isSelected ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear), lineWidth: 1.5)
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
|
||||
}
|
||||
|
||||
private var plateStroke: AnyShapeStyle {
|
||||
if isSelected {
|
||||
AnyShapeStyle(Color.accentColor)
|
||||
} else if Accommodations.drawsRestingBorder(contrast: contrast) {
|
||||
AnyShapeStyle(.separator)
|
||||
} else {
|
||||
AnyShapeStyle(.clear)
|
||||
}
|
||||
}
|
||||
|
||||
private var plateStrokeWidth: CGFloat {
|
||||
Accommodations.borderWidth(isSelected ? 1.5 : 1, contrast: contrast)
|
||||
}
|
||||
|
||||
// MARK: - Rename plumbing
|
||||
@@ -933,7 +973,7 @@ enum LaneSlot: Identifiable {
|
||||
///
|
||||
/// The editing face is an editor — the accent-stroked well the user is typing into. The awaiting
|
||||
/// face is **a card**: the same plate, inset, stripe gutter, icon, font and title position a default
|
||||
/// new card gets (`CardFaceView`, via the `CardFaceMetrics` both read). That is the second half of
|
||||
/// new card gets (`CardFaceView`, via the `BoardMetrics` both read). That is the second half of
|
||||
/// 02-architecture.md ▸ TransientBoardState ▸ overlays' one-arrival rule — the first half is
|
||||
/// `LaneSlot` keying a committed placeholder by the arriving card's identity, which makes the echo
|
||||
/// reload a content swap inside one persistent element; drawing that content identically is what
|
||||
@@ -953,6 +993,13 @@ private struct NewCardStubView: View {
|
||||
|
||||
let openCard: (ItemID) -> Void
|
||||
|
||||
/// Increase Contrast, for the editor well's stroke below (10-accessibility.md; `Accommodations`).
|
||||
@Environment(\.colorSchemeContrast) private var contrast
|
||||
|
||||
/// The live body metric — the same one the real face reads, which is what makes "the numbers are
|
||||
/// the same numbers rather than equal ones" survive the move to font-derived metrics.
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
|
||||
var body: some View {
|
||||
switch phase {
|
||||
case .editing: editor
|
||||
@@ -978,23 +1025,32 @@ private struct NewCardStubView: View {
|
||||
)
|
||||
.font(.body)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(CardFaceMetrics.contentPadding)
|
||||
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
.fill(.background.secondary)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius)
|
||||
.strokeBorder(Color.accentColor.opacity(0.6), lineWidth: 1.5)
|
||||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
// Increase Contrast takes the well to full strength and a point heavier — a 60%-alpha
|
||||
// outline is exactly the kind of border the setting exists to rescue
|
||||
// (`Accommodations`).
|
||||
.strokeBorder(
|
||||
Color.accentColor.opacity(Accommodations.accentOpacity(0.6, contrast: contrast)),
|
||||
lineWidth: Accommodations.borderWidth(1.5, contrast: contrast)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// **The arriving card, drawn a round trip early.** Every line below has a counterpart in
|
||||
/// `CardFaceView.body`/`titleRow`, and the numbers are the same numbers rather than equal ones
|
||||
/// (`CardFaceMetrics`) — when the echo reload swaps this view for the real face inside the one
|
||||
/// (`BoardMetrics`) — when the echo reload swaps this view for the real face inside the one
|
||||
/// slot they share, nothing about the plate, the icon, the font or the title's position changes.
|
||||
///
|
||||
/// No stripe overlay and no selection stroke: both would be `.clear` for a default, unselected
|
||||
/// new card, and a shape that paints nothing is better left unwritten than written and disabled.
|
||||
private var arrivingFace: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.card)
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.medium)
|
||||
@@ -1004,9 +1060,12 @@ private struct NewCardStubView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(CardFaceMetrics.contentPadding)
|
||||
.padding(.leading, CardFaceMetrics.stripeWidth)
|
||||
.background(RoundedRectangle(cornerRadius: CardFaceMetrics.cornerRadius).fill(.background.secondary))
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.padding(.leading, BoardMetrics.cardStripeWidth(bodyPointSize: pointSize))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
|
||||
.fill(.background.secondary)
|
||||
)
|
||||
}
|
||||
|
||||
/// Commits, then **re-selects the lane** — "Return commits and re-selects the lane (next Return
|
||||
|
||||
Reference in New Issue
Block a user