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:
@@ -1,27 +1,6 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - The card plate's metrics
|
||||
|
||||
/// The card plate's geometry, spelled once because **three views draw it**: the real face
|
||||
/// (`CardFaceView`), the placeholder standing in for a card on its way (`NewCardStubView`'s
|
||||
/// `.awaitingArrival` face), and — since the face itself is what the trash column renders — nothing
|
||||
/// else at all. 02-architecture.md ▸ TransientBoardState ▸ overlays makes the create handoff "read as
|
||||
/// one arrival — the placeholder renders at the arriving card's exact geometry/chrome", and exact is
|
||||
/// only checkable if there is one set of numbers rather than two that happen to agree.
|
||||
enum CardFaceMetrics {
|
||||
/// 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 let cornerRadius: CGFloat = 8
|
||||
/// 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 let stripeWidth: CGFloat = 4
|
||||
/// The plate's inset around its content.
|
||||
static let contentPadding: CGFloat = 10
|
||||
/// Between the icon, the title and the attachments chip.
|
||||
static let rowSpacing: CGFloat = 6
|
||||
}
|
||||
|
||||
// MARK: - Which side of the board a face is on
|
||||
|
||||
/// **The one axis a card face has** — which container it is drawn in, and the collaborator that
|
||||
@@ -130,15 +109,25 @@ struct CardFaceView: View {
|
||||
/// The app-wide quick-style recents — see `LaneView`'s own note.
|
||||
@Environment(AppModel.self) private var appModel
|
||||
|
||||
/// Increase Contrast, for the plate's borders below (10-accessibility.md: "Increase Contrast
|
||||
/// strengthens borders and the selection indicator"). 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 face lays out on is a multiple of it
|
||||
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule). Read here rather than
|
||||
/// passed in, which is `CardAttachmentsSection`'s pattern on the card-window side.
|
||||
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
|
||||
|
||||
/// The plate's corner radius — shared with the accent stripe, which rounds its left corners to
|
||||
/// exactly this so the stripe reads as part of the card's edge rather than a bar laid over it.
|
||||
/// Read from `CardFaceMetrics` rather than spelled here, because the new-card placeholder has to
|
||||
/// Read from `BoardMetrics` rather than spelled here, because the new-card placeholder has to
|
||||
/// draw this same plate for the create handoff to read as one arrival.
|
||||
private var cornerRadius: CGFloat { CardFaceMetrics.cornerRadius }
|
||||
private var cornerRadius: CGFloat { BoardMetrics.cardCornerRadius(bodyPointSize: pointSize) }
|
||||
|
||||
/// K1 · left edge stripe (03-board-ui.md § Styling ▸ Capabilities, settled in the pathfinder's
|
||||
/// treatment shootout).
|
||||
private var stripeWidth: CGFloat { CardFaceMetrics.stripeWidth }
|
||||
private var stripeWidth: CGFloat { BoardMetrics.cardStripeWidth(bodyPointSize: pointSize) }
|
||||
|
||||
/// **The role's three absences, as a branch rather than as disabled modifiers.** Everything both
|
||||
/// sides share is in `face`; what the board has and the trash does not is attached here, so the
|
||||
@@ -188,7 +177,7 @@ struct CardFaceView: View {
|
||||
private var face: some View {
|
||||
titleRow
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(CardFaceMetrics.contentPadding)
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
// Constant, whether or not a stripe paints: every card's text sits on the same grid, so
|
||||
// colouring a card never shifts its title relative to its uncoloured neighbours.
|
||||
.padding(.leading, stripeWidth)
|
||||
@@ -198,12 +187,14 @@ struct CardFaceView: View {
|
||||
// highlights while hovered" (04-interactions.md ▸ Drag and drop), and the accent stroke is
|
||||
// already this face's vocabulary for "this one". The hover draws it a touch heavier so a
|
||||
// hovered card that is *also* selected still reads as the target.
|
||||
//
|
||||
// **Increase Contrast strengthens both the ring and the plate's edge** (10-accessibility.md):
|
||||
// the stroke goes a point heavier, and an *unselected* card — which normally floats on its
|
||||
// fill alone — gains a separator hairline, because "this is one card and that is another" is
|
||||
// exactly the distinction the setting exists to rescue (`Accommodations`).
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: cornerRadius)
|
||||
.strokeBorder(
|
||||
isSelected || isFileHovered ? AnyShapeStyle(Color.accentColor) : AnyShapeStyle(.clear),
|
||||
lineWidth: isFileHovered ? 2.5 : 1.5
|
||||
)
|
||||
.strokeBorder(plateStroke, lineWidth: plateStrokeWidth)
|
||||
)
|
||||
// The deferred cut's dim (04-interactions.md ▸ Clipboard: "cut items dim in place until paste
|
||||
// moves them"). Above `contentShape` so the face stays fully clickable while it waits. It
|
||||
@@ -402,7 +393,7 @@ struct CardFaceView: View {
|
||||
replicaFace
|
||||
}
|
||||
.overlay(alignment: .topTrailing) { DragCountBadge(count: count) }
|
||||
.padding(12)
|
||||
.padding(BoardMetrics.replicaPadding(bodyPointSize: pointSize))
|
||||
}
|
||||
|
||||
/// A static rendition of the face — a drag image is a snapshot, so it carries no gestures, no
|
||||
@@ -411,7 +402,7 @@ struct CardFaceView: View {
|
||||
/// deregister it when the image went away, quietly stealing the card from the rubber band and the
|
||||
/// arrow keys).
|
||||
private var replicaFace: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 6) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
@@ -421,9 +412,12 @@ struct CardFaceView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
attachmentsIndicator
|
||||
}
|
||||
.padding(10)
|
||||
.padding(BoardMetrics.cardContentPadding(bodyPointSize: pointSize))
|
||||
.padding(.leading, stripeWidth)
|
||||
.frame(width: 220, alignment: .leading)
|
||||
// The replica has no lane to measure itself against, so it takes a representative card
|
||||
// width — font-derived like everything else here, so the image under the cursor is the size
|
||||
// the cards on the board actually are at this text size (`BoardMetrics`).
|
||||
.frame(width: BoardMetrics.cardReplicaWidth(bodyPointSize: pointSize), alignment: .leading)
|
||||
.background(RoundedRectangle(cornerRadius: cornerRadius).fill(.background.secondary))
|
||||
.overlay(alignment: .leading) { accentStripe }
|
||||
}
|
||||
@@ -563,7 +557,7 @@ struct CardFaceView: View {
|
||||
// MARK: - Title row
|
||||
|
||||
private var titleRow: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: CardFaceMetrics.rowSpacing) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.cardRowSpacing(bodyPointSize: pointSize)) {
|
||||
Image(systemName: ItemSymbol.name(card.icon, fallback: ItemSymbol.card))
|
||||
.foregroundStyle(iconTint)
|
||||
.imageScale(.medium)
|
||||
@@ -659,6 +653,29 @@ struct CardFaceView: View {
|
||||
store.selection.container == role.container && store.selection.ids.contains(card.id)
|
||||
}
|
||||
|
||||
/// What the plate's edge is painted with: the accent when this card is selected or a Finder file
|
||||
/// drag is hovering it, a separator hairline under Increase Contrast, and nothing otherwise.
|
||||
///
|
||||
/// The three-way branch is the whole of "state is never colour-alone, and Increase Contrast
|
||||
/// strengthens borders" meeting on one shape: the resting border is *chrome* (every card gets
|
||||
/// one, so it says nothing), and the accent ring stays the only thing that means "this one".
|
||||
private var plateStroke: AnyShapeStyle {
|
||||
if isSelected || isFileHovered {
|
||||
AnyShapeStyle(Color.accentColor)
|
||||
} else if Accommodations.drawsRestingBorder(contrast: contrast) {
|
||||
AnyShapeStyle(.separator)
|
||||
} else {
|
||||
AnyShapeStyle(.clear)
|
||||
}
|
||||
}
|
||||
|
||||
private var plateStrokeWidth: CGFloat {
|
||||
guard isSelected || isFileHovered else {
|
||||
return Accommodations.borderWidth(1, contrast: contrast)
|
||||
}
|
||||
return Accommodations.borderWidth(isFileHovered ? 2.5 : 1.5, contrast: contrast)
|
||||
}
|
||||
|
||||
/// Whether an external Finder file drag is hovering **this** card — the attach highlight
|
||||
/// (`DragSession.fileAttachTarget`). The face declares no drop target of its own: the hover is
|
||||
/// resolved analytically inside the lane's delegate, which is what keeps single-target dispatch
|
||||
|
||||
Reference in New Issue
Block a user