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:
2026-07-29 08:48:25 -04:00
parent c339b4cecf
commit 8564814754
21 changed files with 1419 additions and 218 deletions
+11 -1
View File
@@ -48,7 +48,17 @@ final class LaneDropRegistry {
/// The height a card with no registered measurement is assumed to have a lane whose faces have
/// not laid out yet. Nominal rather than zero, so the resting rows still tile.
static let nominalCardHeight: CGFloat = 44
///
/// **Font-derived** (`BoardMetrics.nominalCardHeight`, 10-accessibility.md's full-relative-scaling
/// rule), and it matters more here than at most sites: this is the stand-in the drop model tiles
/// rows with before anything has measured itself, so a figure fixed at 13pt's 44 points would put
/// every un-measured slot boundary in the wrong place at a large system text size and the drop
/// model is forbidden from reading measured frames mid-flight (03-board-ui.md § Motion), so this
/// guess is all it has until the lane lays out.
@MainActor
static var nominalCardHeight: CGFloat {
BoardMetrics.nominalCardHeight(bodyPointSize: BoardMetrics.bodyPointSize)
}
/// The board strip's own frame in the window's SwiftUI global space the origin the strip
/// coordinates `DropSlotMath.laneExtents` is written in are measured from. It lives here rather
+10 -3
View File
@@ -138,8 +138,12 @@ struct BoardInfoView: View {
private let hasGitDirectory: Bool
/// The style editor brings its own padding, so the sections around it carry the same number by
/// hand instead of an outer padding that would double up on it.
private let inset: CGFloat = 14
/// hand instead of an outer padding that would double up on it **the editor's own figure**
/// (`StyleEditorLayout.sectionSpacing`), which is font-derived, so the popover's chrome scales
/// with the grids inside it (10-accessibility.md's full-relative-scaling rule).
private var inset: CGFloat {
StyleEditorLayout.sectionSpacing(bodyPointSize: CardWindowMetrics.bodyPointSize)
}
init(store: BoardStore, recents: StyleRecents) {
self.store = store
@@ -178,7 +182,10 @@ struct BoardInfoView: View {
.padding(inset)
}
}
.frame(width: 268)
// The style editor's popover width, taken from the editor rather than restated: the embed
// below must lay out here exactly as it does at its other two anchors, and that number is
// now font-derived.
.frame(width: StyleEditorLayout.popover(bodyPointSize: CardWindowMetrics.bodyPointSize).width)
}
/// The section titles, matching the style editor's own headers so the popover reads as one
+246
View File
@@ -0,0 +1,246 @@
import AppKit
import CoreGraphics
/// The board strip's geometry **derived from font metrics, never written down in points**
/// (10-accessibility.md Text scaling & visual accommodations: "relative text styles everywhere, no
/// fixed point sizes. Card face, lane header, and masonry metrics derive from font metrics, so
/// layout survives the largest system text sizes").
///
/// This is `CardWindowMetrics`' twin on the board side, and deliberately the same shape: a pure
/// arithmetic surface parameterised on the body font's point size, plus one impure read of what that
/// point size currently is. Everything the strip draws that is not a piece of text the inter-lane
/// gap, a lane's plate inset, a card's corner radius and stripe, the masonry's card spacing, the
/// trash column's header is a multiple of that size, so the board grows with the system text size
/// instead of squeezing text into chrome sized for 13pt.
///
/// ### Why a point size and not a `Font`
///
/// Because layout takes numbers. SwiftUI's relative text styles handle the *text* (and every `Text`
/// on the board wears one `.body`, `.headline`, `.caption`); what they cannot do is tell an
/// `.padding()` how much room the text will need. So the two halves of "full relative scaling" are
/// split by mechanism: type styles for glyphs, this surface for everything between them, both keyed
/// to the same system font.
///
/// ### The em, and what the multiples mean
///
/// Every figure below is a multiple of the body point size an *em*, roughly chosen so that at
/// the standard 13pt system body font it reproduces the numbers the board already drew. That is
/// deliberate: this milestone is meant to make the board *scale*, not to redesign it, so the default
/// text size must render pixel-for-pixel what it rendered before. The multiples are what carries the
/// design to 18pt, 24pt and beyond.
///
/// Results are rounded to whole points (SwiftUI will happily lay out on halves, but a hairline
/// border on a half-point boundary blurs) and floored at 1 for anything that is a width or a height,
/// so no proposal is ever zero or negative.
///
/// ### The no-horizontal-scroll invariant is untouched
///
/// 03-board-ui.md § Layout full visibility divides the window's width across the lanes' width
/// units, and `LaneLayoutMath` takes the gap as a parameter. A larger text size therefore means a
/// larger gap and *narrower* lanes, never a wider strip: "the degenerate case is accepted, not
/// floored titles and cards truncate gracefully". The truncation rules are the views' own
/// (`lineLimit(1)` + `.tail` on a lane title, `lineLimit(4)` on a card title), and they hold at every
/// scale because they are stated in lines rather than in points.
enum BoardMetrics {
// MARK: - The unit
/// `multiple` ems of the body font, rounded to a whole point and floored at one.
///
/// Floored rather than clamped to zero because every caller is a length: a spacing of zero is a
/// legitimate design choice, but none of the figures below is one, and a rounding that produced
/// zero would silently collapse a stripe or a band rather than shrink it.
static func em(_ multiple: CGFloat, bodyPointSize: CGFloat) -> CGFloat {
max(1, (bodyPointSize * multiple).rounded())
}
// MARK: - The strip
/// The inter-lane gap **and** the strip's outer margin one number, because the standard-width
/// formula counts `units + 1` of them (`LaneLayoutMath.standardWidth`).
///
/// 0.9 em: 12pt at the standard 13pt body, which is what the strip has always drawn.
static func stripGap(bodyPointSize: CGFloat) -> CGFloat {
em(0.9, bodyPointSize: bodyPointSize)
}
// MARK: - The lane
/// 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.
static func laneCornerRadius(bodyPointSize: CGFloat) -> CGFloat {
em(0.75, bodyPointSize: bodyPointSize)
}
/// The lane plate's inset around its header and its masonry.
static func lanePlatePadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.45, bodyPointSize: bodyPointSize)
}
/// Between the lane's header and its card stack.
static func laneStackSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.6, bodyPointSize: bodyPointSize)
}
/// Between the header's glyph, its title and its count badge.
static func laneHeaderSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.45, bodyPointSize: bodyPointSize)
}
/// The header row's own horizontal inset inside the plate.
static func laneHeaderInset(bodyPointSize: CGFloat) -> CGFloat {
em(0.3, bodyPointSize: bodyPointSize)
}
/// C7 · full-column top edge (03-board-ui.md § Styling Capabilities) the lane accent band's
/// height.
static func laneAccentBandHeight(bodyPointSize: CGFloat) -> CGFloat {
em(0.4, bodyPointSize: bodyPointSize)
}
/// The trailing room the header reserves for the new-card button, so **a long title truncates
/// before it collides with the button** rather than running under it.
///
/// This is the one figure that is not decoration: the button is an `Image` at
/// `.imageScale(.small)`, which *is* a relative size, so a reserve fixed at 22pt would be
/// overrun by the glyph itself at a large system text size and the truncation rule would stop
/// being true. 1.7 em is the button plus its breathing room, measured in the same unit the glyph
/// grows in.
static func newCardButtonReserve(bodyPointSize: CGFloat) -> CGFloat {
em(1.7, bodyPointSize: bodyPointSize)
}
/// The count badge's capsule inset horizontal and vertical, which are deliberately different:
/// a capsule around a single digit wants width, not height.
static func badgeHorizontalPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.45, bodyPointSize: bodyPointSize)
}
static func badgeVerticalPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.08, bodyPointSize: bodyPointSize)
}
// MARK: - The card face
/// 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 func cardCornerRadius(bodyPointSize: CGFloat) -> CGFloat {
em(0.6, bodyPointSize: bodyPointSize)
}
/// 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 func cardStripeWidth(bodyPointSize: CGFloat) -> CGFloat {
em(0.3, bodyPointSize: bodyPointSize)
}
/// The card plate's inset around its content.
static func cardContentPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.75, bodyPointSize: bodyPointSize)
}
/// Between the icon, the title and the attachments chip.
static func cardRowSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.45, bodyPointSize: bodyPointSize)
}
/// The masonry's spacing between interior columns and between stacked cards within a column.
///
/// The lane registers this into `LaneDropRegistry.Grid`, so the drop model's analytic resting
/// grid replays the same number the layout drew with and no second derivation exists to drift
/// (DRAG-REORDER.md § The card masonry).
static func cardSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.6, bodyPointSize: bodyPointSize)
}
/// The height a card with no registered measurement is assumed to have a lane whose faces have
/// not laid out yet, and the shadow a Finder file drop opens for a card that does not exist.
///
/// 3.4 em: one body line of title inside two content paddings, plus the plate's own rhythm.
/// Nominal rather than zero, so the resting rows still tile.
static func nominalCardHeight(bodyPointSize: CGFloat) -> CGFloat {
em(3.4, bodyPointSize: bodyPointSize)
}
// MARK: - The drag replica
/// The width the card drag's replica is drawn at a card at a representative lane width, since
/// the image under the cursor has no lane to measure itself against.
static func cardReplicaWidth(bodyPointSize: CGFloat) -> CGFloat {
em(17, bodyPointSize: bodyPointSize)
}
/// The transparent margin around a drag replica, which is what keeps its shadow from being
/// clipped by the drag image's bounds.
static func replicaPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.9, bodyPointSize: bodyPointSize)
}
/// The lane replica's floor dimensions, for the frame a lane that has not measured itself yet
/// would otherwise be drawn at.
static func laneReplicaMinimumWidth(bodyPointSize: CGFloat) -> CGFloat {
em(6, bodyPointSize: bodyPointSize)
}
static func laneReplicaMinimumHeight(bodyPointSize: CGFloat) -> CGFloat {
em(9, bodyPointSize: bodyPointSize)
}
// MARK: - The lane resize handle
/// The invisible grab strip at a lane's trailing edge, and how far right it is shifted so most
/// of it hangs into the inter-lane gap rather than sitting over the lane's own scrollbar
/// (`LaneResizeHandle`). Both scale, because the gap they live in does.
static func resizeHandleWidth(bodyPointSize: CGFloat) -> CGFloat {
em(0.9, bodyPointSize: bodyPointSize)
}
static func resizeHandleOverhang(bodyPointSize: CGFloat) -> CGFloat {
em(0.6, bodyPointSize: bodyPointSize)
}
// MARK: - The trash column
/// The trash header's inset horizontal and vertical (03-board-ui.md § Trash Rendering).
static func trashHeaderHorizontalPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.75, bodyPointSize: bodyPointSize)
}
static func trashHeaderVerticalPadding(bodyPointSize: CGFloat) -> CGFloat {
em(0.6, bodyPointSize: bodyPointSize)
}
/// The gap between the trash header's diagonal hatch strokes.
///
/// It scales for a reason the other figures do not share: the hatch is **the trash's non-colour
/// distinction** (10-accessibility.md's never-colour-alone rule "the trash header is hatched
/// plus labeled"), and a fixed 7pt pitch behind text twice its usual size reads as a texture
/// rather than as hatching.
static func trashHatchSpacing(bodyPointSize: CGFloat) -> CGFloat {
em(0.55, bodyPointSize: bodyPointSize)
}
// MARK: - The board window
/// The board window's minimum content size two standard lanes' worth of width and enough
/// height for a header and a few cards. Derived so a large system text size cannot leave the
/// window smaller than one lane's own header.
static func windowMinimumSize(bodyPointSize: CGFloat) -> CGSize {
CGSize(
width: em(49, bodyPointSize: bodyPointSize),
height: em(31, bodyPointSize: bodyPointSize)
)
}
// MARK: - The live metric
/// The body font's point size as the system currently reports it.
///
/// **The app asks the system exactly once, in `CardWindowMetrics.bodyPointSize`**, and this
/// forwards to it: the board and the card window must agree about what "the body font" is, or a
/// card face and the window it opens into would scale on two different rulers.
@MainActor
static var bodyPointSize: CGFloat {
CardWindowMetrics.bodyPointSize
}
}
+14 -4
View File
@@ -447,16 +447,26 @@ struct BoardSearchBar: View {
let store: BoardStore
let presentation: BoardSearchPresentation
/// Reduce Transparency **this bar is the board's one glass underlay** ("glass underlays go
/// solid, wherever they appear", 10-accessibility.md; the design's own example, the card face
/// carousel's page dots, died with the carousel). `.bar` is a material, so under the setting it
/// becomes the opaque window background (`Accommodations.Underlay`).
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 0) {
Spacer(minLength: 0)
BoardSearchFieldView(store: store, presentation: presentation)
.frame(width: 220)
// A field wide enough for a query, in characters rather than points, so it grows
// with the text it holds (10-accessibility.md's full-relative-scaling rule).
.frame(width: BoardMetrics.em(17, bodyPointSize: pointSize))
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(.bar)
.padding(.horizontal, BoardMetrics.stripGap(bodyPointSize: pointSize))
.padding(.vertical, BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
.background(Accommodations.underlay(reduceTransparency: reduceTransparency).style)
Divider()
}
+5 -1
View File
@@ -101,7 +101,11 @@ enum BoardToolbar {
"Search",
identifier: .boardSearch,
symbol: nil,
behavior: .control(width: 220) { [weak store] willBeInserted in
// The field's width in *characters* rather than points (10-accessibility.md Text
// scaling: "no fixed point sizes") the same 17 ems the transient bar's field takes
// (`BoardSearchBar`), so F's two homes are one width whichever the user is in.
behavior: .control(width: BoardMetrics.em(17, bodyPointSize: BoardMetrics.bodyPointSize)) {
[weak store] willBeInserted in
guard willBeInserted, let store else {
return BoardSearchFieldController.makePaletteField()
}
+39 -10
View File
@@ -103,6 +103,9 @@ struct BoardView: View {
/// from the environment here and handed to `Motion`, which owns what "reduced" means for each.
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Increase Contrast, for the marquee band's border below (10-accessibility.md; `Accommodations`).
@Environment(\.colorSchemeContrast) private var contrast
/// Whether the strip holds keyboard focus, which is what makes the grammar keys arrive. Restored
/// deliberately whenever an inline editor closes: the field that had focus is gone, and Return
/// must go back to meaning create/rename rather than nothing at all.
@@ -110,7 +113,13 @@ struct BoardView: View {
/// The inter-lane gap, and the strip's outer margin one number, because the standard-width
/// formula counts `units + 1` of them (03-board-ui.md § Layout; `LaneLayoutMath.standardWidth`).
private let spacing: CGFloat = 12
///
/// **Font-derived** (`BoardMetrics.stripGap`), which is what keeps the no-horizontal-scroll
/// invariant honest under 10-accessibility.md's full-relative-scaling rule: a larger system text
/// size widens the gap and therefore *narrows* every lane, since the window's width still divides
/// across `units + 1` gaps. The strip never grows and never scrolls; the lanes compress, "the
/// degenerate case accepted, not floored" (03-board-ui.md § Layout full visibility).
private var spacing: CGFloat { BoardMetrics.stripGap(bodyPointSize: BoardMetrics.bodyPointSize) }
var body: some View {
GeometryReader { proxy in
@@ -169,11 +178,20 @@ struct BoardView: View {
.popover(isPresented: styleEditorPresentation(store, anchor: nil), arrowEdge: .top) {
StyleEditorPopover(store: store, recents: appModel.styleRecents)
}
// The board is a focus target so the grammar keys reach it at all. The focus *ring* is off:
// the strip is the window's content, not a control, and a rectangle around the whole board
// would read as an error state.
// **The board is one tab stop** (10-accessibility.md Full Keyboard Access: "the board is
// one tab stop with arrow-key navigation within"), and this is it: one focusable strip,
// whose interior movement is the arrow grammar below rather than a focus stop per card.
// The controls *around* the cards each lane's new-card button, the popovers, the toolbar
// are ordinary `Button`s and stay Tab-reachable in their own right, which is the other half
// of the same sentence ("every control is Tab-reachable").
//
// The focus *ring* is normally off: the strip is the window's content, not a control, and a
// rectangle around the whole board would read as an error state. **Under Full Keyboard
// Access it comes back**, because that reasoning inverts completely there FKA's premise is
// that the user can see where Tab landed, and an invisible tab stop is not one
// (`Accommodations.isFullKeyboardAccessEnabled`).
.focusable()
.focusEffectDisabled()
.focusEffectDisabled(!Accommodations.isFullKeyboardAccessEnabled)
.focused($isBoardFocused)
.onAppear {
isBoardFocused = true
@@ -270,7 +288,7 @@ struct BoardView: View {
// One of the drag's N contiguous shadows, at the exact width the arriving lane
// will occupy its units measured against *this* strip's standard, which is
// what makes the drop land precisely where the shadow shows.
DragShadow()
DragShadow(cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize))
.frame(width: LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing))
.frame(maxHeight: .infinity)
}
@@ -335,7 +353,15 @@ struct BoardView: View {
Rectangle()
.fill(Color.accentColor.opacity(0.12))
.frame(width: rect.width, height: rect.height)
.overlay(Rectangle().strokeBorder(Color.accentColor.opacity(0.5), lineWidth: 1))
// Increase Contrast takes the band's edge to full strength and a point heavier: the
// fill is a 12%-alpha wash by design, so the border is the only thing that says where
// the sweep actually reaches (`Accommodations`).
.overlay(
Rectangle().strokeBorder(
Color.accentColor.opacity(Accommodations.accentOpacity(0.5, contrast: contrast)),
lineWidth: Accommodations.borderWidth(1, contrast: contrast)
)
)
.offset(x: rect.minX, y: rect.minY)
.allowsHitTesting(false)
}
@@ -396,9 +422,12 @@ struct BoardView: View {
let slotWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: spacing)
ZStack(alignment: .topLeading) {
if resizing {
DragShadow(dashed: false)
.frame(width: slotWidth)
.frame(maxHeight: .infinity)
DragShadow(
cornerRadius: BoardMetrics.laneCornerRadius(bodyPointSize: BoardMetrics.bodyPointSize),
dashed: false
)
.frame(width: slotWidth)
.frame(maxHeight: .infinity)
}
// Interior columns follow the SNAPPED unit count while this lane is being resized a
// column count is integral, so it tracks k (which ticks and animates), not the live
+51 -34
View File
@@ -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
+27 -8
View File
@@ -14,21 +14,32 @@ import SwiftUI
/// target stays live beneath them"), and a shadow that swallowed the release would strand the drop.
struct DragShadow: View {
/// Matched to the surface it stands in for a lane's plate is 10, a card's is 8.
var cornerRadius: CGFloat = 10
/// Matched to the surface it stands in for the lane's plate radius or the card's, both
/// font-derived (`BoardMetrics`) and both supplied by the caller, which is the one that knows
/// which surface this shadow is standing in for.
let cornerRadius: CGFloat
/// A drop proposal (dashed) or a resting footprint (plain).
var dashed: Bool = true
/// Increase Contrast and Reduce Transparency, for the outline and the fill below
/// (10-accessibility.md; `Accommodations`). A shadow's whole job is to say "here", and both
/// halves of how it says so are alpha-based by default.
@Environment(\.colorSchemeContrast) private var contrast
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
var body: some View {
RoundedRectangle(cornerRadius: cornerRadius)
.fill(.quaternary.opacity(0.5))
.fill(Accommodations.dragShadowWash(reduceTransparency: reduceTransparency).style)
.overlay {
if dashed {
RoundedRectangle(cornerRadius: cornerRadius)
.strokeBorder(
Color.accentColor.opacity(0.55),
style: StrokeStyle(lineWidth: 1.5, dash: [6])
Color.accentColor.opacity(Accommodations.accentOpacity(0.55, contrast: contrast)),
style: StrokeStyle(
lineWidth: Accommodations.borderWidth(1.5, contrast: contrast),
dash: [6]
)
)
}
}
@@ -46,16 +57,24 @@ struct DragCountBadge: View {
let count: Int
/// The badge is a disc around a numeral, so every figure in it follows the numeral's font
/// (`BoardMetrics`, 10-accessibility.md's full-relative-scaling rule) at the standard body size
/// they are the 6, 3 and 10 points it has always drawn.
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
var body: some View {
if count > 1 {
Text("\(count)")
.font(.caption2.bold())
.monospacedDigit()
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.padding(.horizontal, BoardMetrics.em(0.45, bodyPointSize: pointSize))
.padding(.vertical, BoardMetrics.em(0.25, bodyPointSize: pointSize))
.background(Circle().fill(Color.accentColor))
.offset(x: 10, y: -10)
.offset(
x: BoardMetrics.em(0.75, bodyPointSize: pointSize),
y: -BoardMetrics.em(0.75, bodyPointSize: pointSize)
)
}
}
}
+7 -4
View File
@@ -36,11 +36,14 @@ struct LaneResizeHandle: View {
/// cursor.
@State private var cursorPushed = false
private let handleWidth: CGFloat = 12
/// The grab strip's width and its rightward shift both font-derived, because the inter-lane
/// gap they are proportioned against is (`BoardMetrics.stripGap`, 10-accessibility.md's
/// full-relative-scaling rule). At the standard body size they are the 12pt and 8pt the strip
/// has always used: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
/// into the gap (clear of the lane's own scrollbar).
private var handleWidth: CGFloat { BoardMetrics.resizeHandleWidth(bodyPointSize: BoardMetrics.bodyPointSize) }
/// Rightward shift: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
/// into the gap (clear of the scrollbar).
private let overhang: CGFloat = 8
private var overhang: CGFloat { BoardMetrics.resizeHandleOverhang(bodyPointSize: BoardMetrics.bodyPointSize) }
var body: some View {
Color.clear
+91 -32
View File
@@ -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
+31 -13
View File
@@ -90,12 +90,23 @@ struct TrashLaneView: View {
/// specifically ("and trash animations all get reduced variants").
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// Reduce Transparency, for the column's two washes below "glass underlays go solid, wherever
/// they appear" (10-accessibility.md). The trash plate and its hatched header are the board's
/// only translucent surfaces, and they sit over a background the *user* chose (03-board-ui.md §
/// Styling), which is exactly the case the setting exists for (`Accommodations.Wash`).
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
/// The live body metric this column's geometry is `LaneView`'s, derived from the same font
/// (`BoardMetrics`), because these are the same cards in a column that must read as their
/// sibling.
private var pointSize: CGFloat { BoardMetrics.bodyPointSize }
/// The lane plate's corner radius matched to `LaneView`'s so the column reads as a sibling of
/// the lanes rather than as a different kind of object.
private let cornerRadius: CGFloat = 10
private var cornerRadius: CGFloat { BoardMetrics.laneCornerRadius(bodyPointSize: pointSize) }
/// Between the cards `LaneView.cardSpacing`, because these are the same cards.
private let cardSpacing: CGFloat = 8
private var cardSpacing: CGFloat { BoardMetrics.cardSpacing(bodyPointSize: pointSize) }
var body: some View {
VStack(alignment: .leading, spacing: 0) {
@@ -104,7 +115,7 @@ struct TrashLaneView: View {
}
.background(
RoundedRectangle(cornerRadius: cornerRadius)
.fill(.quaternary.opacity(0.35))
.fill(Accommodations.trashPlateWash(reduceTransparency: reduceTransparency).style)
)
// **The delete gesture's drop target**, over the whole column the header included, since
// the ruling is "dropping a live card on the shown trash", not on one of its rows. It
@@ -186,7 +197,7 @@ struct TrashLaneView: View {
/// It carries no gesture at all: no selection (the column "is never selectable as a lane"), no
/// reorder drag, no context menu.
private var header: some View {
HStack(alignment: .firstTextBaseline, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: BoardMetrics.laneHeaderSpacing(bodyPointSize: pointSize)) {
Image(systemName: "trash")
.foregroundStyle(.secondary)
.imageScale(.medium)
@@ -198,13 +209,16 @@ struct TrashLaneView: View {
countBadge
Spacer(minLength: 0)
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.padding(.horizontal, BoardMetrics.trashHeaderHorizontalPadding(bodyPointSize: pointSize))
.padding(.vertical, BoardMetrics.trashHeaderVerticalPadding(bodyPointSize: pointSize))
.background {
UnevenRoundedRectangle(topLeadingRadius: cornerRadius, topTrailingRadius: cornerRadius)
.fill(.quaternary.opacity(0.5))
.fill(Accommodations.trashHeaderWash(reduceTransparency: reduceTransparency).style)
.overlay {
DiagonalHatch()
// The hatch pitch scales with the text (`BoardMetrics.trashHatchSpacing`): this
// is the trash's **non-colour** distinction, and a fixed 7pt pitch behind a
// headline twice its usual size reads as a texture rather than as hatching.
DiagonalHatch(spacing: BoardMetrics.trashHatchSpacing(bodyPointSize: pointSize))
.stroke(.quaternary, lineWidth: 1)
.clipShape(UnevenRoundedRectangle(
topLeadingRadius: cornerRadius,
@@ -224,8 +238,8 @@ struct TrashLaneView: 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))
}
@@ -274,7 +288,7 @@ struct TrashLaneView: View {
// The delete gesture's shadow, holding the topmost row open
// (04-interactions.md The trash). At the nominal card height: the cards
// being proposed have no face here yet to be measured.
DragShadow(cornerRadius: CardFaceMetrics.cornerRadius)
DragShadow(cornerRadius: BoardMetrics.cardCornerRadius(bodyPointSize: pointSize))
.frame(maxWidth: .infinity)
.frame(height: LaneDropRegistry.nominalCardHeight)
}
@@ -306,7 +320,7 @@ struct TrashLaneView: View {
// column's gesture surface is full height" (04-interactions.md The trash, settled)
// needs that blank space to actually belong to the view the gesture below is on.
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding(6)
.padding(BoardMetrics.lanePlatePadding(bodyPointSize: pointSize))
.contentShape(Rectangle())
// The band's trash-side surface. It arms from the column's empty space, full height
// (above) included, so a drag can start from the blank area below the last card exactly
@@ -354,8 +368,12 @@ private enum TrashSlot: Identifiable {
///
/// The lines start a full header-height to the left of the leading edge so the first stroke reaches
/// the top-left corner instead of beginning partway across.
///
/// The pitch is the caller's (`BoardMetrics.trashHatchSpacing`), because it scales with the text:
/// the hatch is the trash's non-colour distinction (10-accessibility.md's never-colour-alone rule),
/// and a distinction that stops reading at a large text size is not one.
private struct DiagonalHatch: Shape {
var spacing: CGFloat = 7
var spacing: CGFloat
func path(in rect: CGRect) -> Path {
var path = Path()